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иretrievedbuilder не заполняет:interpretationожидается от FHIR-слоя выше;retrieved— от Ретривера (до вызоваclassifyOne; сегодня замокан, см. Откуда берётся retrieved) asOfDatecaller передаёт as-is — это дата последнего клинического наблюдения пациента, не «сегодня» (biomarker-actuality-integration)fhirObservationIdLatestObservationвсё ещё не несёт — при порте протащить (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-классификатор читает результат.
Пайплайн на одно наблюдение целиком:
- Диагностик (LLM-агент, шаг 1 параметр-анализа) — берёт биомаркер пациента, ходит в biomarker-graph через tool-calls (
lookup_biomarker/lookup_domain), возвращаетV2DiagnosticPlanсо списком связанных состояний/лекарств иrationale/quotesиз графа. - Ретривер (LLM + code-execution sandbox, шаг 2) — получает
V2DiagnosticPlan+ контекст пациента, идёт в FHIR, разделяет связи наrelatedFound(есть у пациента) иrelatedMissing(граф предложил, но у пациента нет). - Validity-классификатор — берёт
relatedFound.conditions+.medicationsдля своего наблюдения, кладёт вretrieved.matchedConditions/matchedMedications. LLM-вызов (classifyOne) видит это в промпте под заголовком## Graph-derived relevance. - Код-резолвер (
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.
| Поле | Тип | Что |
|---|---|---|
analyte | string | verbatim-копия имени аналита из входа |
shelfClassification | enum 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 |
shelfNote | string | одна фраза про биологию аналита, поместившую его на эту полку — без контекста пациента |
oneAndDoneMarker | bool | true ⟺ аналит — клинически «измерен один раз, и готово»: результат не устаревает, ретест не нужен (anti-HBs / anti-HBc / HBsAg status, anti-TPO / anti-CCP / anti-dsDNA при установленном диагнозе). Код использует это, чтобы исключить аналит из оси устаревания (см. pastShelf) |
activeStateAssessment[] | array | Полный перебор: ровно один элемент на каждый item из activeConditions + activeMedications (conditions первыми в input-порядке, потом medications), обе пустые → []. Нельзя пропустить state «потому что очевидно нерелевантен» — он эмитится с materialRelevance: "no". Поэлементно ↓ |
└ state | string | verbatim-копия строки состояния посимвольно (held-квалификатор — уточнение «препарат временно приостановлен», напр. "metformin, held pre-op" — сохраняется) |
└ _ladderTrace | string ≤~25 слов | поле-черновик: модель сначала пишет сюда, какую ветку правила она выбрала ("held qualifier → no" / "matched in graph (<graphName>) → yes" / "not in graph matches → no"), и только после этого заполняет materialRelevance. Смысл — заставить её честно пройти правило сверху вниз, а не подгонять. В выход не идёт: код это поле выбрасывает |
└ materialRelevance | enum yes|no | по 3-шаговой лестнице (held → graph-match → default) — § «Лестница materialRelevance» ниже |
└ reasonCode | enum | какая ветка лестницы сработала: 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-валидаторе) |
└ axisRedrawn | enum 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" |
└ note | string | одна фраза для ревьюера: на yes — назвать механизм; на no — "held" или "not surfaced by retriever" |
reasoning | string (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):
- Held / paused / discontinued. Если строка состояния (verbatim из
patientContext.activeConditions/activeMedications, то, что LLM видит в промпте под «Active conditions/medications») несёт квалификатор «препарат не в теле» —held/paused/discontinued/stopped/d/cили явный эквивалент →materialRelevance: "no",reasonCode: "held". Перебивает graph-match — held-препарат это не активная биология. - Graph-match. Иначе — ищем эту строку в блоке
## Graph-derived relevance(его построил ретривер изbiomarker_graph.json; LLM видит его в промпте). Есть →materialRelevance: "yes",reasonCode: "primary-axis". Биологию ретривер уже установил — LLM не переоценивает, только читает вывод. - 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— verbatimstate-строки всех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 драйвер). Тогда: флагN→currently_representative_with_caveat; флагL/H/A→likely_outdated; флаг отсутствует →likely_outdated(защитный дефолт). - D — устарело по дате, драйверов нет:
0 драйверовИpastShelfИ продление не применилось →likely_outdated. - E — есть драйверы, в пределах полки:
≥1 драйвер→currently_representative_with_caveat. - F — чисто: иначе →
currently_representative.
- A — lifelong:
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): берёт одно наблюдение + контекст пациента и:
- собирает промпт (
buildUserMessage— as-of date; контекст пациента двумя пронумерованными verbatim-списками состояний; строка наблюдения с interpretation-флагом если есть; блок## Graph-derived relevanceот ретривера); - шлёт в модель (
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, и часто проскакивает детерминированное зависание; - проверяет вывод runtime-валидатором (
validateLLMClassification); - ок → декорирует код-полями (
decorateClassification→resolveClassification), готово; валидатор отклонил → один повтор с прицельной подсказкой (rejectionsToRetryHint); и повтор не прошёл → ставитfinalStatus: "unknown"-строку. Если сам вызов упал после всех ретраев — тожеunknown-строка.
classifyBatch / classifyBatchWithSplit — back-compat-обёртки (оставлены для совместимости со старым кодом-оркестратором, который всё ещё передаёт список наблюдений). Внутри — Promise.all(classifyOne). Оставлены только чтобы старые импорты не сломались.
Классы сбоев
Три класса сбоя одного вызова, каждый — на свой слой (общая таксономия — llm-call-failure-classes):
- Транспорт — Gemini завис mid-decode / timeout /
503/ECONNRESET/ socket hang up. Обработка здесь: retry до 3× + nonce-perturbation → fallbackopenai/gpt-4.1-mini; не помогло → этому наблюдению строкаunknown. → инфра/прокси-кандидат (llm-proxy-choice, Vertex зависание mid-decode). - Схема / structured-output — Vertex/Gemini не держит надёжно
.strict()/.max()/.refine()/ discriminated unions (поэтомуonset— flat-shape, не DU; «жёсткие» инварианты — в runtime-валидаторе;cleanSchemaстрипает Zod/ai-sdk-артефакты до вызова). → тоже инфра/прокси. - Семантика / контент — модель «забыла» state / verbatim-несовпадение / поля не консистентны. Остался в v3 без изменений:
validateLLMClassification→ reject → ≤1 retry с прицельной подсказкой (rejectionsToRetryHint) → degrade tounknown-строка. → 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.ts — classifyOne, callOnce, buildUserMessage, classifyBatch, classifyBatchWithSplit, makeFallbackRow; VALIDITY_PROMPT_VERSION = "validity-classifier-v3" |
| Публичный orchestrator | packages/analysis-core/src/services/validity-classifier.service.ts — classifyPatient, chunk, process-global семафор |
| Input-builder | packages/analysis-core/src/services/validity-input-builder.ts — buildValidityInput, 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.ts — LLMClassificationSchemaV3, LatestObservation/RetrievedRelevance/RetrievedMatch, ValidityClassification, RouteTrace, LLM_FINAL_STATUSES/RUNTIME_FINAL_STATUSES |
| Код-резолвер | packages/analysis-validity/src/classifier.resolver.ts — resolveClassification, routeFinalStatus, resolveExtension, decorateClassification, daysBetween |
| Runtime-валидатор | packages/analysis-validity/src/classifier.runtime-validate.ts — validateLLMClassification, rejectionsToRetryHint, isStrictISODate |
| Синтетический domain-validity stub (не сам классификатор) — вопрос к Артуру: нужен ли при порте, раз граф-ретривер его заменяет? | packages/analysis-validity/src/{index,types,stub-table}.ts — getValidityForDomain(domain) → ValidityResolution (lookup: retest-интервал + invalidating-conditions + цитаты, на каждый домен графа; все значения фейковые, помечено «not for production»), assertNoSyntheticInProd (deploy-гейт — кидает, если синтетическая цитата попала в прод), STUB_TABLE (22 domain-строки). Классификатором не используется; это слой, с которым граф-ретривер концептуально соседствует — замена STUB_TABLE = реальный экспорт из analyses_termins/ |
| Eval-suite | packages/analysis-core/eval/validity/ — CONCEPT.md, README.md, fixtures/*.json (тестовые «пациенты» с expected-блоком, на которых гоняют классификатор при разработке промпта) |
| Модель-резолвер | packages/analysis-core/src/lib/model.ts — agentModel, 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-judgecron (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.
| Ось | v2 | v3 |
|---|---|---|
| Гранулярность | батч из 8 наблюдений на вызов, общий patient-context на батч | 1 наблюдение на вызов; classifyBatch/classifyBatchWithSplit — back-compat-обёртки (Promise.all(classifyOne)); VALIDITY_BATCH_SIZE дефолт = 1 |
| Что эмитит LLM | 9-полевая схема, включая finalStatus (4 значения), asymptomaticExtensionApplies, extensionNote | только структурные факты (LLMClassificationSchemaV3): shelf, oneAndDoneMarker, activeStateAssessment[] с onset/axisRedrawn/reasonCode/_ladderTrace, reasoning без вердикта |
finalStatus/extension/contextSignals | LLM (дерево решения в промпте) + 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.urltop-level вclassifier.mastra.ts— CJS-bundle-готча: подanalysis-worker(esbuild, strict-mode)import.meta.urlundefined и крэшнет; при порте заменить на__dirname/require.resolve/ data-uri- Self-rolled worker-pool / process-global семафор в
classifyPatient— антипаттерн (no-self-rolled-queues), при порте на Inngest fan-out @repo/analysis-validitysynthetic stub (getValidityForDomain, 22 fake domain-строки сassertNoSyntheticInProddeploy-гейтом) — классификатором не используется, остался как концептуальный сосед граф-ретривера; судьба при порте — вопрос к Артуру
Связано
- 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-validitysynthetic 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