Per-observation LLM-сервис, который для каждого лабораторного значения отвечает на вопрос «можно ли вообще опираться на это значение сейчас, или оно устарело / искажено активным драйвером (диагнозом или лекарством, влияющим на аналит) / биологически неизменно». Раньше назывался «validity classifier» — название актуальность закрепили на созвоне с Артуром 2026-05-12 (более точно описывает смысл: «валидность» слишком широко, «репрезентативность» неоднозначно, «релевантность» зависит от контекста). Внутрикодовые имена (validity-classifier, LLMClassificationSchemaV3, classifier.service.ts) пока не переименованы — это отдельный refactoring.

Эта страница описывает текущую архитектуру (ветка Артура origin/feat/v2-5, HEAD 64d83432). Архитектура в одну строку: один LLM-вызов = одно наблюдение; LLM эмитит только факты (не вердикт — полка аналита, onset состояния, axisRedrawn, materialRelevance по правилу ретривера); finalStatus, продление-горизонта и contextSignals считает детерминистический код; а «какие активные состояния пациента материально важны для этого аналита» — приносит отдельный upstream-компонент (выход Ретривера из biomarker-analysis-pipeline, см. ниже).

(Прежняя архитектура — батчинг по 8 наблюдений, LLM сам эмитит finalStatus, бисекция упавшего батча — на validity-classifier (снимок коммита 0da5fc41); чем именно отличается v3 — § «Отличия от прежней архитектуры» в конце.)

Что это и зачем

На каждое лабораторное наблюдение (analyte, value, unit, date, refRange, FHIR interpretation-флаг) плюс контекст пациента (активные диагнозы / лекарства, demographics) плюс граф-derived relevance — классификатор отвечает: можно ли опираться на это значение сейчас. Это другой вопрос, чем у biomarker-analysis-pipeline («что значит это отклонение в контексте»). Он не диагностирует, не рекомендует даты ретеста, не выводит скрытые состояния из паттернов в самих лабах — state-сигналами считаются только записи в activeConditions / activeMedications.

I/O-контракт

Что есть что: вход — ValidityClassifierInput; промежуточный тип — LLMClassificationSchemaV3 (то что эмитит LLM, не финал); финальный аутпут на наблюдение — ValidityClassification (LLM-факты + вычисленные кодом поля), собранный в PatientValidityProfile.

ValidityClassifierInput (classifier.types.ts):

type LatestObservation = {
  analyte: string;        // имя биомаркера; для джойна не используется — join по позиционному индексу
                          // (kandidat na udalenie: при graph-driven LLM знать имя не обязан — оставлено для контекста)
  value: string | number; // (kandidat na udalenie: interpretation-флага достаточно — Артур согласен)
  unit?: string;          // (kandidat na udalenie: тоже избыточно при наличии interpretation)
  date: string;           // ISO YYYY-MM-DD
  refRange?: string;      // (kandidat na udalenie: интерпретация уже учла рендж)
  interpretation?: "N" | "L" | "H" | "A";   // FHIR ObservationInterpretation: Normal / Low / High / Abnormal
                                            // — единственное, что LLM реально использует
  retrieved?: RetrievedRelevance;            // вход от граф-ретривера (см. § «Граф-ретривер» ниже)
};
type RetrievedRelevance = {
  graphFound: boolean;                       // нашёлся ли аналит (или его домен) в biomarker_graph.json
  graphKey?: string;
  matchedConditions: RetrievedMatch[];       // активные диагнозы пациента, которые граф связывает с этим аналитом
  matchedMedications: RetrievedMatch[];      // то же для лекарств
};
type RetrievedMatch = {
  // (TBD: возможно стоит хранить не дубль полей, а ссылку на FHIR-resource +
  //  graph-node-key; rationale/quotes/source тянуть из графа по ссылке — Артур
  //  согласен, что сейчас это только для audit-трейса, не для логики LLM.)
  patientString: string;   // verbatim-копия строки состояния из patientContext (посимвольно)
  graphName: string;       // под каким именем оно есть в графе
  rationale: string;       // почему граф считает это влияющим (текст из графа)
  quotes?: string;
  source?: string;
};
type PatientContextBundle = {
  demographics: { sex?: string; ageYears?: number };
  // LLM из них больше ничего не выводит (связи приходят из графа); единственный
  // потребитель — `resolveExtension` (gate возраст ∈ [18, 75) для metabolic-shelf).
  activeConditions: string[];
  activeMedications: string[];
};
type ValidityClassifierInput = {
  asOfDate: string;        // ISO YYYY-MM-DD — «опорная дата», от которой считается «устарело/актуально»
  patientContext: PatientContextBundle;
  observations: LatestObservation[];
};

Отдельный вопрос — как хранить граф. biomarker_graph.json сейчас лежит в коде, его неудобно версионировать и катить; JSON-файл — не production-вариант. Детали и варианты решения — на странице biomarker-graph (раздел «Открытые вопросы») и в large-data-files-storage.

Вход строит чистая функция buildValidityInput(V2PatientContext, asOfDate) (validity-input-builder.ts) — без FHIR-I/O:

  • Latest-per-analyte дедуп — «для каждого уникального биомаркера — последнее значение по date» (ключ loinc:<code>, иначе name:<lowercased trimmed standardizedName||name>). Скорее всего дублирует работу Ретривера: основная дедупликация по идее должна происходить там (Ретривер отдаёт уже сгруппированное по уникальным биомаркерам); validity-input-builder делает это «на всякий случай». Проверить при интеграции.
  • Отбрасывает наблюдения без date или value
  • summarizeCondition — предобработка строк active-conditions/medications: принимает и FHIR-shape, и flat-строку, применяется ко всему списку. Нужна для verbatim-стабильности (LLM должна видеть в промпте те же самые строки, что потом сматчит ретривер).
  • Поля interpretation и retrieved builder не заполняет: interpretation ожидается от FHIR-слоя выше; retrieved — от Ретривера (до вызова classifyOne; сегодня замокан, см. Откуда берётся retrieved)
  • asOfDate caller передаёт as-is — это дата последнего клинического наблюдения пациента, не «сегодня» (biomarker-actuality-integration)
  • fhirObservationId LatestObservation всё ещё не несёт — при порте протащить (llm-fhir-linkback)

Выход — PatientValidityProfile:

type PatientValidityProfile = {
  asOfDate: string;
  modelId: string;                  // Артур добавлял для A/B тестирования моделей; для прода
                                    // годится как версионирование / audit-метка, но это часть
                                    // общего вопроса «как централизованно метить prompt+model+code-version»
                                    // (LangFuse / decorator-pattern) — TBD сквозное решение
  promptVersion: string;            // "validity-classifier-v3" — вручную-поддерживаемый лейбл
  classifications: ValidityClassification[];
};

Публичная функция — classifyPatient(input, options?), возвращает обёртку:

{
  profile: PatientValidityProfile;
  totalBatches: number;   // при VALIDITY_BATCH_SIZE=1 (дефолт) = количество наблюдений
  failedBatches: number;
  retriedBatches: number;
  warnings: number;   // счётчик «тихих фолбэков», которые Артур не любит:
                      // когда что-то деградировало под капотом (fallback на другую модель,
                      // retry с другим nonce и т.д.), но финальный ответ всё-таки получили.
                      // Тихий фолбэк на ДРУГОЙ finalStatus Артур убрал — это бред,
                      // в v3 такого нет.
}

Откуда берётся retrieved

retrieved? поле каждого LatestObservationне отдельный «граф-ретривер» внутри validity. Это вывод Ретривера из мультистадийного пайплайна параметр-анализа, отфильтрованный до активных состояний пациента. Связи биомаркера с диагнозами/лекарствами достаёт Диагностик из biomarker-graph — Ретривер потом проверяет по FHIR, какие из них реально есть у пациента, и validity-классификатор читает результат.

Пайплайн на одно наблюдение целиком:

  1. Диагностик (LLM-агент, шаг 1 параметр-анализа) — берёт биомаркер пациента, ходит в biomarker-graph через tool-calls (lookup_biomarker / lookup_domain), возвращает V2DiagnosticPlan со списком связанных состояний/лекарств и rationale/quotes из графа.
  2. Ретривер (LLM + code-execution sandbox, шаг 2) — получает V2DiagnosticPlan + контекст пациента, идёт в FHIR, разделяет связи на relatedFound (есть у пациента) и relatedMissing (граф предложил, но у пациента нет).
  3. Validity-классификатор — берёт relatedFound.conditions + .medications для своего наблюдения, кладёт в retrieved.matchedConditions/matchedMedications. LLM-вызов (classifyOne) видит это в промпте под заголовком ## Graph-derived relevance.
  4. Код-резолвер (classifier.resolver.ts) — вычисляет finalStatus, contextSignals, routeTrace, pastShelf из структурных фактов LLM (подробнее в Код-резолвер — вердикт, продление-горизонта, contextSignals).

Этот RetrievedRelevance кладётся дословно в user-message классификатора под ## Graph-derived relevance. Зачем: в v2 LLM сам решал «какие состояния важны для этого аналита» из своих медицинских знаний; теперь биологию уже сделал Диагностик + biomarker-graph, и задача LLM-validity — прочитать вывод, а не выводить связи заново. Как именно LLM это использует — Лестница materialRelevance — центральное правило.

Текущее состояние интеграции

retrieved? помечен опциональным и в коде Артура замокан — Validity-классификатор пока не подключён к настоящему Ретриверу. В eval-фикстурах поле заполняется напрямую из biomarker_graph.json (это период разработки, не прод-реализация); в проде интеграции пока нет, это сшивка (biomarker-actuality-integration).

Если retrieved отсутствует — классификатор трактует как «no graph-derived drivers» (все не-held состояния → materialRelevance: "no"). В полноценной v3-интеграции поле обязано быть заполнено.

Связано: biomarker-actuality-thresholdsстатичный TTL-фильтр актуальности (старое прямолинейное решение «биомаркер живёт N дней»). Validity v3 его заменяет: TTL остаётся одним из gate’ов в код-резолвере, но validity учитывает ещё активные драйверы, axis-redrawn, lifelong-маркеры. Domain-lookup @repo/analysis-validity (синтетический v0-stub) — legacy, видимо часть старого мока; при graph-driven подходе не нужен.

Что эмитит LLM — структурные факты

Один LLM-вызов выдаёт по одному наблюдению набор фактов (не вердикт): на какой «полке» аналит по своей биологии, какие активные состояния пациента важны/нет и почему, был ли драйвер недавним, плюс human-readable reasoning. Финальный вердикт (finalStatus), арифметику устаревания, продление-горизонта (например: «биомаркер обычно валиден 3 месяца, но при таком-то активном состоянии горизонт короче / длиннее») — всё это считает код-резолвер из этих фактов.

Follow-ups здесь не считаются. Теоретически из тех же данных можно было бы рассчитать «когда пересдавать» — но в v3 этого пока нет, рекомендация ретеста — отдельная задача.

Порядок полей в схеме фиксирован — Vertex/Gemini structured-output генерирует поля в порядке схемы, поэтому порядок полей = порядок рассуждения модели. В частности, внутри элемента activeStateAssessment поле _ladderTrace (_-prefix = дебаг-поле; код выбрасывает до финального аутпута) стоит перед materialRelevance намеренно (см. строку _ladderTrace ниже). Приём «порядок полей = chain-of-thought» — structured-output-field-order-cot.

ПолеТипЧто
analytestringverbatim-копия имени аналита из входа
shelfClassificationenum acute|metabolic|lifelong|unclearна какой «полке» аналит по его собственной биологии (контекст пациента тут не смотрят): acute — горизонт интерпретации одного значения ~1 мес (INR, troponin, лактат, ABG, ammonia, CBC под острой перестройкой); metabolic — ~3 мес (creatinine/BUN, электролиты, fasting glucose, lipids, HbA1c, TSH, vitamin/mineral stores, baseline LFTs/CBC); lifelong — биологически неизменный результат (germline genotype, группа крови, HLA, стабильный somatic driver); unclear — аналит незнаком или биологически неоднозначен. Carve-out: клинически one-and-done маркеры (durable serology, autoantibody уже установленного аутоиммунного диагноза) — это metabolic + oneAndDoneMarker: true, не lifelong
shelfNotestringодна фраза про биологию аналита, поместившую его на эту полку — без контекста пациента
oneAndDoneMarkerbooltrue ⟺ аналит — клинически «измерен один раз, и готово»: результат не устаревает, ретест не нужен (anti-HBs / anti-HBc / HBsAg status, anti-TPO / anti-CCP / anti-dsDNA при установленном диагнозе). Код использует это, чтобы исключить аналит из оси устаревания (см. pastShelf)
activeStateAssessment[]arrayПолный перебор: ровно один элемент на каждый item из activeConditions + activeMedications (conditions первыми в input-порядке, потом medications), обе пустые → []. Нельзя пропустить state «потому что очевидно нерелевантен» — он эмитится с materialRelevance: "no". Поэлементно ↓
statestringverbatim-копия строки состояния посимвольно (held-квалификатор — уточнение «препарат временно приостановлен», напр. "metformin, held pre-op" — сохраняется)
_ladderTracestring ≤~25 словполе-черновик: модель сначала пишет сюда, какую ветку правила она выбрала ("held qualifier → no" / "matched in graph (<graphName>) → yes" / "not in graph matches → no"), и только после этого заполняет materialRelevance. Смысл — заставить её честно пройти правило сверху вниз, а не подгонять. В выход не идёт: код это поле выбрасывает
materialRelevanceenum yes|noпо 3-шаговой лестнице (held → graph-match → default) — § «Лестница materialRelevance» ниже
reasonCodeenumкакая ветка лестницы сработала: held (= no, held/paused/discontinued-препарат) / primary-axis (= yes, был в matched-списке ретривера) / no-active-biology (= no, не в matched-списке и не held). Legacy-значения (systemic / cross-organ-exemplar / cadence / measured-for / downstream-risk) схема принимает, но промпт под graph-driven-контрактом их эмитить запрещает
onset{ kind: explicit|relative|none, value?, phrase? }когда состояние началось, по verbatim-строке: explicit — есть однозначно-нормализуемая календарная дата → value (ISO); relative — относительная фраза («3 weeks ago», «longstanding», «new diagnosis») или locale-неоднозначная дата → phrase (verbatim); none — temporal-cue нет вообще. При сомнении — relative/none, угадывать запрещено; никогда не вычислять дату из относительной фразы + as-of. (Flat-shape, не discriminated union — Vertex криво держит DU; cross-field-консистентность — в runtime-валидаторе)
axisRedrawnenum yes|no|not_applicableдля yes-драйвера: структурно ли это состояние переписало физиологическую ось аналита (старое измерение теперь описывает другую физиологию)? yes — thyroidectomy → TSH, dialysis → creatinine-как-GFR, started insulin → fasting glucose, glucocorticoid → TSH (центральная супрессия HPT-оси). no — вероятностные side-effects (vancomycin → Cr) и количественные сдвиги в той же оси (CKD → creatinine, ACEi → K, tacrolimus → Mg, STEMI → HbA1c/lipids). not_applicable — для каждого materialRelevance:"no"
notestringодна фраза для ревьюера: на yes — назвать механизм; на no"held" или "not surfaced by retriever"
reasoningstring (1–3 предлож.)синтез только структурных фактов (полка, сколько прошло времени, какие state’ы graph-matched и почему). Запрещено называть вердикт («representative» / «outdated» / «with caveat» / «lifelong» или эквивалент) — вердикт считает код, и verdict-проза тут разойдётся с вычисленным ответом и засорит audit-трейс

После этого набора фактов к выходу (ValidityClassification) код добавляет вычисленные поля: observationIndex, finalStatus, asymptomaticExtensionApplies + extensionNote, contextSignals, routeTrace, pastShelf. Это и есть финальный аутпут, который читают downstream-потребители — в v2 их эмитил LLM, в v3 их считает код.

Что из этого реально использовать сегодня: только finalStatus. Остальное (asymptomaticExtensionApplies/extensionNote/contextSignals/routeTrace/pastShelf) — для аудита и отладки; downstream-генерация на них пока не опирается (Артур подтвердил на созвоне). pastShelf — bool «наблюдение старше горизонта своей полки» (см. Код-резолвер — вердикт, продление-горизонта, contextSignals).

Итого: LLM делает медицинское суждение про сам аналит и про активные состояния (полка, oneAndDoneMarker, axisRedrawn, onset-форма, текст), а «какие состояния вообще важны» (materialRelevance) ставит почти механически по выводу ретривера.

Терминология: «ось» (axis) и «полка» (shelf) — это одно и то же в этом проекте (Артур подтвердил). У нас три «оси/полки»: acute / metabolic / lifelong (+ unclear). axisRedrawn = «активное состояние переписало физиологическую ось аналита» = «эффективно сдвинуло его на другую полку».

axisRedrawn модель обязана ставить честно на каждом yes-элементе — по медицинскому существу, а не «лениво в no, раз код всё равно не прочитает»: код читает axisRedrawn только в одном узком случае (активное состояние-драйвер — диагноз или лекарство с materialRelevance: "yes" — с явной датой позже даты наблюдения), но модель заранее не знает, какой элемент туда попадёт, а ленивый дефолт корраптит audit-трейс.

Лестница materialRelevance — центральное правило

Это инструкция в промпте LLM (шаг 2 пайплайна). Для каждого элемента activeStateAssessment[] — три шага сверху вниз (это та «лестница», которую цитирует _ladderTrace):

  1. Held / paused / discontinued. Если строка состояния (verbatim из patientContext.activeConditions / activeMedications, то, что LLM видит в промпте под «Active conditions/medications») несёт квалификатор «препарат не в теле» — held / paused / discontinued / stopped / d/c или явный эквивалент → materialRelevance: "no", reasonCode: "held". Перебивает graph-match — held-препарат это не активная биология.
  2. Graph-match. Иначе — ищем эту строку в блоке ## Graph-derived relevance (его построил ретривер из biomarker_graph.json; LLM видит его в промпте). Есть → materialRelevance: "yes", reasonCode: "primary-axis". Биологию ретривер уже установил — LLM не переоценивает, только читает вывод.
  3. Default. Иначе → materialRelevance: "no", reasonCode: "no-active-biology" — ретривер не surface’ил это состояние для этого аналита, и оно не held. Не выдумывать связь; не тащить adverse-effect-профили лекарств / downstream-risk-рассуждения.

Если блок ретривера пуст или отсутствует — каждый не-held элемент падает на шаг 3.

Код-резолвер — вердикт, продление-горизонта, contextSignals

classifier.resolver.ts берёт факты от LLM плюс даты и interpretation-флаг и считает остальное. Вызов: resolveClassification(llm, ctx), где

ctx = { asOfDate: string, obsDate: string, interpretation?: "N"|"L"|"H"|"A", age?: number }
// Сюда не входят `analyte` / `value` / `unit` / `refRange` — те же поля, которые
// помечены как kandidat-na-udalenie во входном `LatestObservation`. Резолверу нужны
// только дата + interpretation-флаг + возраст. Подтверждает, что value/unit/range
// действительно избыточны end-to-end.
// → { finalStatus, asymptomaticExtensionApplies, extensionNote, contextSignals, routeTrace, pastShelf }

Это чистая арифметика дат + enum-lookup’ы — никаких regex по клиническим строкам, никаких keyword-таблиц биомаркеров/состояний (это жёсткое требование от Артура: LLM регулярно предлагает «давай заматчим терминологию регексом» — нет, нельзя, граф решает).

  • pastShelf — флаг «наблюдение старше горизонта своей полки»: !oneAndDoneMarker && ageDays > horizon, где ageDays = asOfDate − obsDate, horizon = { acute: 30 дней, metabolic: 90 дней, lifelong: ∞, unclear: ∞ }. One-and-done-маркеры из-под этого правила исключены.
  • contextSignals — verbatim state-строки всех materialRelevance:"yes"-записей (т.е. «какие активные состояния стоит держать в уме рядом с этим значением»).
  • routeFinalStatus — маршрутизация A→F, тоже «лестница»: первый сработавший шаг даёт finalStatus. В v2 это делал LLM (дерево решений жило в промпте), в v3 переехало в код-резолвер. Эволюция «промпт → код» сама по себе достойна отдельной decision-страницы — TBD оформить, заодно зафиксировать v2-vs-v3 сравнение.
    • A — lifelong: shelf === "lifelong"lifelong_no_retest.
    • B — axis-redrawn: есть драйвер с onset.kind === "explicit" и onset.value > obsDate И axisRedrawn === "yes"likely_outdated (наблюдение сделано до того, как ось переписали).
    • C — арбитраж по interpretation-флагу на развилке caveat↔outdated: срабатывает, если gate-i (есть драйвер с explicit-датой > obsDate) или gate-ii (pastShelf И ≥1 драйвер). Тогда: флаг Ncurrently_representative_with_caveat; флаг L/H/Alikely_outdated; флаг отсутствует → likely_outdated (защитный дефолт).
    • D — устарело по дате, драйверов нет: 0 драйверов И pastShelf И продление не применилось → likely_outdated.
    • E — есть драйверы, в пределах полки: ≥1 драйверcurrently_representative_with_caveat.
    • F — чисто: иначе → currently_representative.
  • routeTrace — строка-метка, какой именно веткой получился finalStatus. Буквально один из кодов: A-lifelong / B-axis-redrawn / C-gate-i-N / C-gate-i-LHA / C-gate-ii-N / C-gate-ii-LHA / C-fork-absent-default / D-stale-no-driver / E-caveat-with-driver / F-clean. Ни на что не влияет — нужна только для audit-логов в clinical-device-контексте.
  • resolveExtension — asymptomatic-screening продление горизонта: только для shelf === "metabolic" (иначе not_applicable); applies: "yes" ⟺ возраст ∈ [18, 75) и 0 драйверов и ageDays ≤ 365 (внешний cap) и interpretation не L/H/A. До 18 — педиатрия, после 75 — гериатрия; у обеих групп своя физиология, и v3 их явно выводит из этого gate’а (Артур + Ильдар договорились: «делаем для относительно взрослых здоровых; у детей и стариков совсем другая физиология»). Для возрастов вне [18, 75) — no с шаблонной extensionNote. Перепроверить: Артур уточнил, что в v3 (где materialRelevance идёт от ретривера) возраст вообще не доходит до LLM, остался только этот единственный gate в resolveExtension.
  • decorateClassification(...) — обёртка для агента: берёт факты от LLM, прогоняет resolveClassification, собирает готовый ValidityClassification.

Статусов — 4 + 1

finalStatus — 4 значения от код-резолвера + 1 рантайм-сентинел (sentinel = специальное «дозорное» значение: не медицинский ответ, а сигнал инфраструктурного сбоя). Потребитель выхода обязан уметь все 5.

  • currently_representative — значение актуально, активных драйверов нет; читать как есть.
  • currently_representative_with_caveat — значение актуально по времени, но ≥1 активный драйвер искажает чтение / cadence; читать с поправкой (что за драйвер — в contextSignals).
  • likely_outdated — за горизонтом полки для этого пациента, или наблюдение сделано до того, как появился активный драйвер с явной датой (B/C-ветки); значение, вероятно, уже не отражает текущее состояние.
  • lifelong_no_retest — биологически неизменный аналит (shelf === "lifelong"); ретест бессмыслен.
  • unknown (не от LLM и не от резолвера) — инфраструктурный сигнал: LLM не ответил (транспорт), или ответил структурно невалидно и retry не помог. Это не «модель медицински не уверена» — модель unknown не эмитит никогда. При unknown мы просто не знаем валидность этого наблюдения. Имя стоит пересмотреть — error точнее передаёт смысл (это не «неизвестно», это «не получилось»).

Консистентность finalStatus с остальными полями — correct-by-construction: код выводит вердикт из структурных фактов, отдельной проверки (как был валидатор в v2) не требуется.

Открытый интеграционный вопрос. На что finalStatus реально влияет? На summary-генератор — почти очевидно (likely_outdated-параметры идут в summary с меткой «устарело», возможно вообще не идут). Но влияет ли он на связи в Ретривере — неактуальный параметр должен означать, что связь к нему уже не «активная»? Это создаёт цикл (Ретривер → Validity → влияет на следующий Retriever), и Артур на созвоне 2026-05-12 явно эту задачу из текущего скоупа исключил («сначала валидность; ретривер с учётом актуальности — отдельная задача, понимания пока нет»). См. biomarker-actuality-integration.

Один прогон / один вызов

Один прогонclassifyPatient(input, options?) (validity-classifier.service.ts): оркестрирует весь пациент:

  • режет observations на «батчи» (VALIDITY_BATCH_SIZE, дефолт 1 — то есть по одному наблюдению), запоминая offset каждого для реиндекса;
  • гоняет их через worker-pool: до VALIDITY_CONCURRENCY (дефолт 4; Артур при тестировании поднимал до 10) воркеров параллельно; параллелизм ограничен process-global семафором («tighten only» — лимит можно только ужесточить, и он действует на ВСЕ classifyPatient в процессе);
  • на каждый успешный «батч» — реиндекс observationIndex += offset; на упавший — строки unknown (batch-failure) для его наблюдений, весь прогон не валится;
  • собирает PatientValidityProfile + счётчики { totalBatches, failedBatches, retriedBatches, warnings }.

Этот process-global семафор / worker-pool / ручной retry-loop — самодельная in-app concurrency/retry-machinery (no-self-rolled-queues); при порте — на Inngest fan-out; конкретные варианты декомпозиции — Вопрос 3.

Один вызовclassifyOne(...) (classifier.mastra.ts): берёт одно наблюдение + контекст пациента и:

  1. собирает промпт (buildUserMessage — as-of date; контекст пациента двумя пронумерованными verbatim-списками состояний; строка наблюдения с interpretation-флагом если есть; блок ## Graph-derived relevance от ретривера);
  2. шлёт в модель (callOnce): generateObject со схемой LLMClassificationSchemaV3, temperature: 0; primary — vertex/gemini-3-flash-preview, fallback — openai/gpt-4.1-mini; до 3 попыток на primary + 1 на fallback, но fallback включается только если последняя ошибка — транспортный hang (timeout / ECONNRESET / 503 / socket hang up …); таймаут на одну попытку — 90 с (Promise.race); на повторах в промпт дописывается nonce-комментарий (<!-- transport-retry … nonce=… -->) — это меняет начало промпта, промахивается мимо prefix-кэша Vertex, и часто проскакивает детерминированное зависание;
  3. проверяет вывод runtime-валидатором (validateLLMClassification);
  4. ок → декорирует код-полями (decorateClassificationresolveClassification), готово; валидатор отклонил → один повтор с прицельной подсказкой (rejectionsToRetryHint); и повтор не прошёл → ставит finalStatus: "unknown"-строку. Если сам вызов упал после всех ретраев — тоже unknown-строка.

classifyBatch / classifyBatchWithSplit — back-compat-обёртки (оставлены для совместимости со старым кодом-оркестратором, который всё ещё передаёт список наблюдений). Внутри — Promise.all(classifyOne). Оставлены только чтобы старые импорты не сломались.

Классы сбоев

Три класса сбоя одного вызова, каждый — на свой слой (общая таксономия — llm-call-failure-classes):

  1. Транспорт — Gemini завис mid-decode / timeout / 503 / ECONNRESET / socket hang up. Обработка здесь: retry до 3× + nonce-perturbation → fallback openai/gpt-4.1-mini; не помогло → этому наблюдению строка unknown. → инфра/прокси-кандидат (llm-proxy-choice, Vertex зависание mid-decode).
  2. Схема / structured-output — Vertex/Gemini не держит надёжно .strict() / .max() / .refine() / discriminated unions (поэтому onset — flat-shape, не DU; «жёсткие» инварианты — в runtime-валидаторе; cleanSchema стрипает Zod/ai-sdk-артефакты до вызова). → тоже инфра/прокси.
  3. Семантика / контент — модель «забыла» state / verbatim-несовпадение / поля не консистентны. Остался в v3 без изменений: validateLLMClassification → reject → ≤1 retry с прицельной подсказкой (rejectionsToRetryHint) → degrade to unknown-строка. → app-level, не выносится (прокси не знает, что для validity «консистентно»).

Бисекции нет — на гранулярности 1 наблюдение делить нечего. Все три класса дают partial-результат (наблюдение с unknown-строкой), прогон не валится. Счётчики { totalBatches, failedBatches, retriedBatches, warnings } — для инспекции оркестратором; что с ними делать — TBD (вопрос к Артуру).

Где validity сидит в пайплайне

Gate перед параметр-анализом; patient-scoped (запускается через событие analysis/patient-summary.requested); output — скорее ephemeral; куда персистится / кто читает — пока TBD. Это сшивка, не внутрянка — biomarker-actuality-integration.

Сопутствующий вопрос — переиспользование Ретривера. При интеграции Validity-классификатор хочет вызвать Ретривер на patient-scope (по всем биомаркерам сразу), а сам Ретривер сегодня привязан к одному V2DiagnosticPlan за раз (per-test scope). Либо делаем patient-scope-вариант Ретривера, либо отдельный slim-ретривер для validity, либо validity вызывается ВНУТРИ цикла параметр-анализа на каждом тесте. Этот вопрос Артур и Ильдар не закрыли на созвоне 2026-05-12 — в biomarker-actuality-integration.

Где в коде (origin/feat/v2-5 @ 64d83432, Артур)

Пути файлов — HEAD 64d83432; при ребренде/переезде могут измениться.

АртефактРасположение
Per-observation LLM-вызов + back-compat-обёрткиpackages/analysis-core/src/agents/validity-classifier/classifier.mastra.tsclassifyOne, callOnce, buildUserMessage, classifyBatch, classifyBatchWithSplit, makeFallbackRow; VALIDITY_PROMPT_VERSION = "validity-classifier-v3"
Публичный orchestratorpackages/analysis-core/src/services/validity-classifier.service.tsclassifyPatient, chunk, process-global семафор
Input-builderpackages/analysis-core/src/services/validity-input-builder.tsbuildValidityInput, pickLatest, summarizeCondition
Промпт (~19 KB, single-biomarker, graph-driven)packages/analysis-core/src/prompts/validity-classifier/classifier.md — 6 выходных полей, лестница materialRelevance, shelf-reference, onset/axisRedrawn-инструкции, 2 worked-example’а, § Cross-field consistency в хвосте
Типы + Zod-схема LLM-выходаpackages/analysis-validity/src/classifier.types.tsLLMClassificationSchemaV3, LatestObservation/RetrievedRelevance/RetrievedMatch, ValidityClassification, RouteTrace, LLM_FINAL_STATUSES/RUNTIME_FINAL_STATUSES
Код-резолверpackages/analysis-validity/src/classifier.resolver.tsresolveClassification, routeFinalStatus, resolveExtension, decorateClassification, daysBetween
Runtime-валидаторpackages/analysis-validity/src/classifier.runtime-validate.tsvalidateLLMClassification, rejectionsToRetryHint, isStrictISODate
Синтетический domain-validity stub (не сам классификатор) — вопрос к Артуру: нужен ли при порте, раз граф-ретривер его заменяет?packages/analysis-validity/src/{index,types,stub-table}.tsgetValidityForDomain(domain) → ValidityResolution (lookup: retest-интервал + invalidating-conditions + цитаты, на каждый домен графа; все значения фейковые, помечено «not for production»), assertNoSyntheticInProd (deploy-гейт — кидает, если синтетическая цитата попала в прод), STUB_TABLE (22 domain-строки). Классификатором не используется; это слой, с которым граф-ретривер концептуально соседствует — замена STUB_TABLE = реальный экспорт из analyses_termins/
Eval-suitepackages/analysis-core/eval/validity/CONCEPT.md, README.md, fixtures/*.json (тестовые «пациенты» с expected-блоком, на которых гоняют классификатор при разработке промпта)
Модель-резолверpackages/analysis-core/src/lib/model.tsagentModel, cleanSchema

import.meta.url + top-level fileURLToPath всё ещё в classifier.mastra.ts — port-gotcha: под CJS-бандлом analysis-worker import.meta.url undefined → крэшнет (детали — biomarker-actuality-integration § Следствия).

Диаграммы

D1 — control flow одного прогона

classifyPatient(input)
  │  observations → chunk(VALIDITY_BATCH_SIZE=1) → [{items:[obs], offset}, ...]
  │
  ├─ process-global semaphore (VALIDITY_CONCURRENCY=4), worker-pool next++
  │     │  [as-is: in-process — анти-паттерн; target: Inngest fan-out]
  │     ↓  на каждое наблюдение:
  │   classifyBatchWithSplit ≡ classifyBatch → Promise.all(classifyOne)   [split дегенеративен]
  │     │
  │     └─ classifyOne(obs)
  │          │  buildUserMessage: as-of date + patient context (2 verbatim-списка) + ## Observation (+interpretation) + ## Graph-derived relevance
  │          │  callOnce → cleanSchema → generateObject(schema=LLMClassificationSchemaV3, temp=0)
  │          │           ×(≤3 primary +1 fallback), Promise.race(timeout=90s), nonce-perturb на retry, fallback только на retryable transport err
  │          │  → LLMClassification (структурные факты)
  │          │  validateLLMClassification(obs, ctx, llm)            [семантический класс]
  │          │    ├─ ok ──→ decorateClassification → resolveClassification(route A–F + extension + contextSignals + routeTrace + pastShelf) → ValidityClassification ✓
  │          │    └─ reject ──→ rejectionsToRetryHint → callOnce(retryPrompt) → validate
  │          │           ├─ ok ──→ decorateClassification → ✓ (retried)
  │          │           └─ still fails ──→ makeFallbackRow(finalStatus:"unknown", "runtime-validation-failure after retry")
  │          └─ thrown transport error after retries ──→ {success:false} → makeFallbackRow(finalStatus:"unknown", "LLM call failure")  [транспортный класс]
  │
  │  per «батч»: success → reindex observationIndex += offset; fail → "batch-failure" unknown rows  (прогон не валится)
  ↓
PatientValidityProfile { asOfDate, modelId, promptVersion:"validity-classifier-v3", classifications: batchResults.flat() }
  + { totalBatches, failedBatches, retriedBatches, warnings }     [→ persistence: TBD]

D2 — модель данных

FHIR (Google Healthcare API): Patient + Observation×(many) + Condition + MedicationStatement  [+ потенциально MedicationRequest / AllergyIntolerance — зависит от того, что граф умеет интерпретировать]
  │  buildV2PatientContextFromFhir(fhir, patientId)                 [уже есть на нашей ветке]
  ↓
V2PatientContext { demographics, abnormalObservations[], normalObservations[], activeConditions[], activeMedications[] }
  │  buildValidityInput(ctx, asOfDate)  — latest-per-analyte dedup, drop no-date/no-value
  ↓
ValidityClassifierInput { asOfDate, patientContext{demographics, activeConditions[]:string, activeMedications[]:string}, observations[]:LatestObservation }
  │  LatestObservation = { analyte (display-only), value, unit?, date, refRange?, interpretation?:N/L/H/A, retrieved?:RetrievedRelevance }   ← join по позиционному индексу
  │       ⤷ retrieved заполняет РЕТРИВЕР из biomarker-analysis-pipeline (см. [[retriever]] + [[diagnostician]] + [[biomarker-graph]]): matchedConditions/matchedMedications: { patientString, graphName, rationale, ... }   [в eval — мок прямо из graph-фикстур; в проде интеграция ещё не сделана]
  ↓  classifyPatient → (per obs → LLM, schema=LLMClassificationSchemaV3) → validateLLMClassification → decorateClassification → resolveClassification
PatientValidityProfile { asOfDate, modelId, promptVersion, classifications[] }
  │  ValidityClassification (per observation):
  │    [от LLM]   analyte, shelfClassification ∈ {acute,metabolic,lifelong,unclear}, shelfNote, oneAndDoneMarker,
  │               activeStateAssessment[] = [ {state(verbatim), _ladderTrace, materialRelevance ∈ {yes,no}, reasonCode, onset{kind,value?,phrase?}, axisRedrawn ∈ {yes,no,n-a}, note}, ... ]  (len = #conditions + #meds),
  │               reasoning
  │    [от кода]  observationIndex, asymptomaticExtensionApplies ∈ {yes,no,n-a}, extensionNote,
  │               finalStatus ∈ {currently_representative, currently_representative_with_caveat, likely_outdated, lifelong_no_retest, (unknown=runtime-only)},
  │               contextSignals[] = activeStateAssessment.filter(yes).map(state),  routeTrace, pastShelf
  ↓
[куда персистится / кто читает: TBD — нет Prisma-колонок, нет FHIR-host, нет consumer'а; см. decisions/...-pipeline-integration]

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

Закрытые на созвоне 2026-05-12:

  • Имя сервиса — договорились: «сервис актуальности» (не валидности, не репрезентативности, не релевантности). Валидность — слишком широкое; репрезентативность тоже неоднозначно; «актуальность» лучше всего описывает «можно ли опираться на это значение сейчас». Внутрикодовые имена (validity-classifier, validity-classifier-v3) пока оставлены — переименование = отдельный косметический рефакторинг.
  • Синтетический stub (getValidityForDomain, 22 fake domain-строки) — legacy, остаток мока, при порте не нужен.
  • Legacy-значения reasonCode (systemic / cross-organ-exemplar / cadence / measured-for / downstream-risk) — legacy, можно выпилить из Zod-схемы.
  • Имена выходных полей — переименовывать можно. То что НЕ в LLM — точно безопасно; то что В LLM-схеме — тоже можно, но нужно одновременно править промпт.
  • Eval-фикстуры — Артур планирует расширять: текущие JSON-«пациенты под FHIR» с полями для прямых связей вырастают в общий тест-сет (заодно с графом — централизованно/обобщённо, переиспользовать в других пайплайнах). Часть полей к FHIR отношения не имеет, это утилитарные.
  • Частота сбоев — на eval-прогонах сбоев пока не было: при наличии рейт-лимита и стабильном LLM — 0 ошибок (Артур: «290 из 290»).

Открытые к Артуру:

  • Где живёт Ретривер в проде — мы поняли, что нужен не «собственный граф-ретривер», а интеграция с Ретривером из biomarker-analysis-pipeline (см. Откуда берётся retrieved). Открытое — какой scope: один прогон Ретривера на пациента (patient-scope) или вызов внутри per-test цикла. См. biomarker-actuality-integration.
  • Счётчики прогона — что оркестратор делает с { failedBatches, warnings }: логировать обязательно; часть ошибок придётся «глотать» (пропускать наблюдение с error-меткой и идти дальше); ронять весь прогон не хочется. Точная политика — TBD.
  • Модель — production-target: именно vertex/gemini-3-flash-preview или то, на что роутит bifrost (и нужен ли re-tune промпта)? (⊥ gemini-flash-vs-pro-allocation.)
  • Eval harness после порта — остаются ли фикстуры в eval/validity/ (unit-фикстуры для разработки промпта) или переезжают в eval-judge cron (production traces из Langfuse)?

Прочее:

  • maxOutputTokens в generateObject не задан явно — выставить (Vertex зависание mid-decode). Незаданный лимит → Gemini тянет очень длинный ответ → растёт окно для mid-decode timeout; явный cap (~1000 токенов) уменьшает эту вероятность.
  • Терминологияактуальность (settled); analyte / биомаркер / параметр / Observation — ещё открыто, ⊥ health-report-vocabulary.
  • Carry-over: прочитать сам eval-ретривер — мок ли это (рукотворные фикстуры) или тонкая прод-реализация прямого lookup’а по графу (комментарии в коде типов говорят «mocked from biomarker_graph.json», но это не проверено по коду ретривера).
  • Минорный naming-drift в промпте: он ссылается на placeholder’ы [ACTIVE_CONDITIONS] / [ACTIVE_MEDICATIONS], а buildUserMessage эмитит «## Patient context» с «Active conditions / medications (use these strings VERBATIM …)» — функционально одно и то же, но имена расходятся.

Решения по интеграции — работа Ильдара, в biomarker-actuality-integration; здесь не дублирую.

Отличия от прежней архитектуры (v2)

v2 — снимок validity-classifier (commit 0da5fc41, 6 мая); v3-рефактор приземлился 9–10 мая (b28dadd6 «split LLM facts from code-side routing», 4cd7516a «linearize prompt for single-biomarker», d9943869+f50c4630 «graph-driven retrieval»). Что поменялось:

Таблица основана на читке кода; при сомнении — свериться с HEAD 64d83432.

Осьv2v3
Гранулярностьбатч из 8 наблюдений на вызов, общий patient-context на батч1 наблюдение на вызов; classifyBatch/classifyBatchWithSplit — back-compat-обёртки (Promise.all(classifyOne)); VALIDITY_BATCH_SIZE дефолт = 1
Что эмитит LLM9-полевая схема, включая finalStatus (4 значения), asymptomaticExtensionApplies, extensionNoteтолько структурные факты (LLMClassificationSchemaV3): shelf, oneAndDoneMarker, activeStateAssessment[] с onset/axisRedrawn/reasonCode/_ladderTrace, reasoning без вердикта
finalStatus/extension/contextSignalsLLM (дерево решения в промпте) + runtime-валидатор проверяет консистентностькод-резолвер classifier.resolver.ts (маршрутизация A→F, resolveExtension, routeTrace); консистентность correct-by-construction
«Какие состояния важны для аналита»LLM рассуждает из activeConditions/activeMedications + своих мед-знанийupstream граф-ретривер (biomarker_graph.json) → блок ## Graph-derived relevance; materialRelevance LLM ставит почти механически
Бисекцияpoison-batch (split-on-failure, depth ≤3)нет — на гранулярности 1 наблюдение split’ить нечего
promptVersion"validity-classifier-v2""validity-classifier-v3"

Не изменилось:

  • Семантика asOfDate — это дата последнего клинического наблюдения пациента, не «сегодня»; caller передаёт as-is
  • LatestObservation без fhirObservationId — join с FHIR-наблюдением по позиционному индексу; при порте нужно протащить id (llm-fhir-linkback)
  • import.meta.url top-level в classifier.mastra.ts — CJS-bundle-готча: под analysis-worker (esbuild, strict-mode) import.meta.url undefined и крэшнет; при порте заменить на __dirname / require.resolve / data-uri
  • Self-rolled worker-pool / process-global семафор в classifyPatient — антипаттерн (no-self-rolled-queues), при порте на Inngest fan-out
  • @repo/analysis-validity synthetic stub (getValidityForDomain, 22 fake domain-строки с assertNoSyntheticInProd deploy-гейтом) — классификатором не используется, остался как концептуальный сосед граф-ретривера; судьба при порте — вопрос к Артуру

Связано

  • validity-classifier — прежняя (v2) архитектура: снимок 0da5fc41, история и v2-детали (батчинг-8, LLM эмитит finalStatus, бисекция, 9-полевая схема)
  • structured-output-field-order-cot — приём «порядок полей схемы = chain-of-thought», на котором построен _ladderTrace-перед-materialRelevance и порядок LLMClassificationSchemaV3
  • llm-call-failure-classes — таксономия классов сбоев (транспорт / схема / семантика)
  • llm-call-granularity — паттерн «1 / N / батчами»; v3 ушёл к «1»
  • llm-fhir-linkback — линковка LLM-вывода обратно к FHIR Observation; LatestObservation всё ещё без fhirObservationId
  • biomarker-actuality-integration — где в пайплайне (gate перед анализом) + куда output + Inngest-декомпозиция + остальное по интеграции
  • no-self-rolled-queues — почему process-global семафор / worker-pool / ручной retry-loop не портируются как есть
  • biomarker-actuality-thresholds — детерминистический TTL-фильтр актуальности; @repo/analysis-validity synthetic stub — v0 этого слоя, граф-ретривер концептуально соседствует
  • biomarker-analysis-pipeline — соседний V2.5-пайплайн (другой вопрос — «что значит отклонение»); делит V2PatientContext
  • mastra — фреймворк; classifier.mastra.ts назван по нему, но Mastra не использует (голый generateObject из ai-sdk)
  • bifrost / llm-proxy-choice — LLM-прокси; cascade-fallback / nonce-retry / cleanSchema логично вынести туда
  • inngest — orchestrator; целевая площадка для шагов классификатора; § Function vs Step
  • gemini-doom-loop — зависание Vertex/Gemini mid-decode + обходы (nonce-perturbation, cleanSchema)
  • patient-summary — pipeline, в который валидность встраивается
  • structured-llm-service-page-template — мета-шаблон страницы для такого сервиса
  • health-report-vocabulary — несогласованность имён стадий + .mastra-misnomer + терминология
  • gemini-flash-vs-pro-allocation — flash-vs-pro; validity на flash-preview

Источники