Status: superseded. Эта страница — снимок прежней v2 standalone-архитектуры validity-классификатора на ветке Артура
feat/v2-5HEAD0da5fc41(батчинг по 8 наблюдений, LLM сам эмититfinalStatus, рекурсивная бисекция упавшего батча, materialRelevance решается классификатором по removal-test).Текущая архитектура — v3 graph-driven (biomarker-actuality-service): один LLM-вызов на наблюдение, LLM эмитит только факты,
finalStatusсчитает детерминистический код,materialRelevance— lookup в## Graph-derived relevanceсекции, которую готовит Ретривер (single Diag+Retr pass, two consumers — см. biomarker-actuality-integration Вариант A). Интеграция в pipeline — health-report-pipeline Phase 1.Содержимое ниже сохранено as-is для понимания v2-контракта и trade-offs, которые привели к v3. Не использовать как описание текущего кода.
Эта страница разбирает, как устроен validity-классификатор на ветке feat/v2-5 (Артур) — перед портом в наш стек, чтобы (а) обсудить внутренности с Артуром, (б) принять решения по интеграции. Сам порт — отдельная задача (см. biomarker-actuality-integration). В тексте используются имена файлов; полные пути — в разделе «Где в коде».
Что это и зачем
Per-observation LLM-классификатор: на каждое лабораторное наблюдение (analyte, value, unit, date, refRange) плюс контекст пациента (активные диагнозы, активные лекарства, demographics) он выдаёт вердикт — репрезентативно ли это значение для текущего состояния пациента. Модель эмитит вердикт из четырёх статусов; код на failure-путях дописывает пятый — unknown (итого 5, см. § «Статусов — 4 + 1» ниже). Плюс — список «контекстных сигналов» (какие активные состояния пациента — диагнозы / лекарства — классификатор счёл клинически значимыми именно для этого аналита: меняющими, как читать это значение или как часто его пересдавать).
Это другой вопрос, чем у biomarker-analysis-pipeline («что значит это отклонённое значение в контексте?»). Validity-классификатор отвечает «можно ли вообще на это значение опираться сейчас, или оно устарело / искажено активным драйвером / биологически неизменно». Он не диагностирует, не рекомендует даты ретеста, не выводит скрытые состояния из паттернов в самих лабах — только записи в activeConditions / activeMedications считаются state-сигналами.
(Терминология: то, что классификатор называет analyte, в системе называют по-разному — analyte / биомаркер / параметр / Observation; в input-типе классификатора поле названо analyte (выбор Артура), это имя биомаркера как оно пришло из FHIR Observation.code. В самом классификаторе оно display-only; для линковки результата обратно к конкретному FHIR Observation используется не имя, а позиционный индекс — детали в llm-fhir-linkback. Несогласованность имён по стадиям — отдельная боль, health-report-vocabulary.)
Два слоя, которые легко спутать:
- LLM-классификатор — то, что описывает эта страница. Production-grade рассуждение, но по дизайну ещё не подключён на
feat/v2-5: нет Inngest-функции, нет персистентности,PatientValidityProfileпока вызывается только из CLI/eval-скриптов — отдельный, готовый к интеграции модуль. - Пакет
@repo/analysis-validity— имя не совпадает с содержимым. Это синтетический v0-stub: lookup-таблицаgetValidityForDomain(domain) → интервал ретеста + цитаты(stub-table.ts, 22 domain-строки, каждое значение фейковое, помечено «not clinical, not for production»; в коде есть пометка, что когда-нибудь оно заменится «настоящими» validity-данными из доков команды analyses-validity — что именно это за процесс и термины, неясно из кода, вопрос Артуру).getValidityForDomain/assertNoSyntheticInProdсейчас никто не вызывает. Этот же пакет хостит типы классификатора (classifier.types.ts) и runtime-валидатор (classifier.runtime-validate.ts) — вот они используются. Зачем stub-слой лежит в одном пакете с типами классификатора и какова его судьба (может, он нам не нужен для порта; может, превратится в тестовые фикстуры) — тоже вопрос Артуру.
I/O-контракт
ValidityClassifierInput (classifier.types.ts):
type LatestObservation = {
analyte: string; // имя биомаркера (= параметр / Observation — синонимы; имя поля выбрал Артур); display-only (join — позиционный индекс)
value: string | number;
unit?: string;
date: string; // ISO YYYY-MM-DD
refRange?: string;
};
type PatientContextBundle = {
demographics: { sex?: string; ageYears?: number };
activeConditions: string[];
activeMedications: string[];
};
type ValidityClassifierInput = {
asOfDate: string; // ISO YYYY-MM-DD — «опорная дата», не часть patientContext (см. Открытые вопросы)
patientContext: PatientContextBundle;
observations: LatestObservation[];
};Вход строит чистая функция buildValidityInput(V2PatientContext, asOfDate) (validity-input-builder.ts) — без FHIR-I/O; на вход тот же V2PatientContext, что и у biomarker-analysis-pipeline (в нашем стеке его собирает buildV2PatientContextFromFhir из FHIR).
Что делает builder:
- latest-per-analyte дедуп — один элемент на уникальный аналит; ключ
loinc:<code>, иначеname:<lowercased trimmed standardizedName||name>; из дублей берётся максимум поdate(строковое сравнение). - отбрасывает наблюдения без
dateили сvalue === null/undefined. demographics→ageYears/sex(если они нужного типа, иначеundefined).summarizeConditionпоactiveConditions/activeMedications→ строки: терпит и FHIR-shape ({code:{text}}/{code:{coding:[...].display}}) и flat ({display}/{text}/{name}).asOfDate— «опорная дата», относительно которой считается «устарело/актуально»; передаётся caller’ом as-is, builder её не вычисляет. Что именно туда класть — решено (дата последнего клинического наблюдения пациента, не «сегодня»); это решение по интеграции, rationale + кейс «только старые тесты» — biomarker-actuality-integration § Позиции.
fhirObservationId — наполовину уже готов на нашей ветке (V2Observation.fhirObservationId есть, buildV2PatientContextFromFhir его заполняет), но LatestObservation (входной тип классификатора у Артура) его теряет — при порте протащить. Как классификатор линкует выход обратно к конкретному FHIR Observation и как это устроено у нас в per-параметр-анализе — отдельная страница: llm-fhir-linkback.
Выход — PatientValidityProfile:
type PatientValidityProfile = {
asOfDate: string;
modelId: string;
promptVersion: string; // вручную-поддерживаемый лейбл "validity-classifier-v2"
// из VALIDITY_PROMPT_VERSION в classifier.mastra.ts:30 —
// не content-hash, не git-ref, не Langfuse-prompt-version;
// правки в classifier.md сами по себе его не двигают
classifications: ValidityClassification[];
};Публичная функция classifyPatient(input, options?) (validity-classifier.service.ts) возвращает обёртку:
{
profile: PatientValidityProfile;
totalBatches: number;
failedBatches: number;
retriedBatches: number;
warnings: number;
}Выходная схема как CoT
Выход на одно наблюдение — девять полей в фиксированном порядке объявления. Vertex/Gemini structured-output эмитит поля в порядке схемы, поэтому порядок полей = порядок рассуждения — это «структурный CoT» (модель сначала пишет shelf аналита, потом нот про него, потом разбор состояний пациента и т.д.; поздние поля обязаны conform к ранним и не переписывают их; перестановка полей превращает CoT в коррелированный шум; reasoning идёт последним — это audit-резюме цепочки, не вход в неё). Сам приём («порядок полей структурированного вывода = chain-of-thought») — переиспользуемый паттерн, разобран отдельно: structured-output-field-order-cot.
| # | Поле | Тип | Что решает | Что НЕ смотрит |
|---|---|---|---|---|
| 1 | observationIndex | int | Копия индекса из входа (для join обратно). | — |
| 2 | analyte | string | Verbatim-копия имени аналита (только для human review). | — |
| 3 | shelfClassification | enum: acute | metabolic | lifelong | unclear | На какой «полке» аналит по его биологии: acute (флуктуирует за ~1 мес: CRP, WBC, glucose, troponin, electrolytes), metabolic (~3-мес горизонт интерпретации одного значения: HbA1c, lipids, ferritin, TSH, vit D, baseline LFTs/CBC), lifelong (биологически неизменно: germline genotype, ABO/Rh, HLA, stably-present somatic driver), unclear (незнакомый/двусмысленный аналит — «sparingly»). Carve-out: durable serology (anti-HBs/anti-HBc/HBsAg) и autoantibody-маркеры уже установленного аутоиммунного диагноза (anti-TPO/anti-CCP/anti-dsDNA) — не lifelong, это metabolic с date-axis carve-out (не downgrade’ятся в likely_outdated по возрасту). | activeConditions, activeMedications, age, sex, value/date этого наблюдения — shelf это свойство аналита, не пациента. |
| 4 | shelfNote | string (~20 слов) | Одна фраза про биологию аналита, которая поместила его на этот shelf. | Контекст пациента. |
| 5 | activeStateAssessment[] | array of { state: string, materialRelevance: enum yes|no, note: string } | Anti-tagging gate. Исчерпывающая энумерация каждого элемента activeConditions + activeMedications (conditions первыми в input-порядке, потом medications). Длина массива = activeConditions.length + activeMedications.length, обе пустые → []. Каждый элемент: state — verbatim-копия строки посимвольно; materialRelevance — yes ⟺ знающий клиницист изменил бы, как читает ИЛИ как часто переcдаёт именно этот аналит из-за этого состояния («у пациента это есть» само по себе недостаточно; held/discontinued лекарства — всегда no; removal-test: убрал бы только эту запись — изменилась бы интерпретация этого аналита? нет → no); триггеры yes: (a) value/interpretation effect, (b) cadence effect, (c) measured-FOR (аналит, через который этот диагноз измеряют). note — одна фраза ≤15 слов: на yes назвать механизм, на no — почему не драйвер. | Нельзя пропустить state «потому что очевидно нерелевантно» — эмитится с no + one-clause note. Нельзя ввести state, которого нет во входе. |
| 6 | asymptomaticExtensionApplies | enum: yes | no | not_applicable | Применяется ли ~13-месячное asymptomatic-screening продление к этому аналиту: yes ⟺ shelf metabolic + асимптомный взрослый ~18–75 + ни одной materialRelevance:"yes" записи; no = metabolic, но один gate отключил продление (педиатрия / frail elderly 75+ / есть драйвер); not_applicable = shelf не metabolic. | — |
| 7 | extensionNote | string (~20 слов) | Одна фраза с причиной gating. | — |
| 8 | finalStatus | enum (4 значения) | Вердикт — из закрытого набора четырёх (см. § «Статусов — 4 + 1» ниже: значения, дерево решения, predates-driver-триггер). Должен быть логически консистентен с предыдущими полями — проверяется валидатором. | — |
| 9 | reasoning | string (1–3 предложения) | Audit-grade, для человека-ревьюера: shelf, прошедшее время, какие yes-драйверы дали caveat. Без новых клинических фактов, без рекомендаций ретеста, не обращаясь к пациенту. | — |
contextSignals[] — это, простыми словами, список тех активных состояний пациента (диагнозов / лекарств), которые классификатор счёл важными именно для этого биомаркера. Не эмитится LLM напрямую — выводится в коде из activeStateAssessment[]: берутся все записи с materialRelevance === "yes", оттуда state-строки. Пример: для HbA1c у диабетика на метформине contextSignals = ["Type 2 diabetes mellitus", "Metformin 1000 mg BID"] — это «почему к этому значению пометка caveat». (Сами activeConditions / activeMedications, по которым классификатор рассуждает, он не извлекает — они уже структурированы upstream’ом в FHIR-записи пациента (Condition / MedicationStatement, наполненные medical-context-пайплайном); классификатор только фильтрует их по material-релевантности к конкретному аналиту. contextSignals — этот отфильтрованный срез.)
ValidityClassification (то, что отдаётся наружу) = вывод LLM по одному наблюдению + добавленный contextSignals[].
Статусов — 4 + 1
finalStatus — это 4 значения от LLM + 1 рантайм-сентинел от кода. Потребитель выхода (ValidityClassification.finalStatus) обязан уметь все 5 и понимать, что 5-е — не от модели.
currently_representative— значение актуально, ни одногоmaterialRelevance:"yes"-драйвера; читать как есть.currently_representative_with_caveat— значение актуально по времени, но ≥1 активный драйвер искажает чтение / cadence; читать с поправкой (что за драйвер — вcontextSignals[]).likely_outdated— за пределами shelf-горизонта для этого пациента, или наблюдение сделано ДО появления активного драйвера в детерминистической паре (predates-driver-триггер) — значение, вероятно, уже не отражает текущее состояние.lifelong_no_retest— биологически неизменный аналит (shelf === "lifelong"): ретест бессмыслен, значение валидно навсегда.unknown(не от LLM — дописывает код на failure-путях) — пайплайн не выдал классификацию для этого наблюдения (LLM-вызов упал после всех попыток / валидация не прошла после retry / split-half-failure). ≠ «модель не уверена»; модель самаunknownне эмитит.
Дерево решения (для четырёх LLM-статусов): shelf === "lifelong" → lifelong_no_retest; иначе ≥1 materialRelevance:"yes" → ..._with_caveat; иначе 0 yes → currently_representative; за пределами shelf-горизонта для этого пациента → likely_outdated. Отдельный predates-driver триггер: наблюдение сделано до появления активного драйвера + пара «детерминистична» → likely_outdated независимо от времени; из конкретных пар врачом подтверждена только glucocorticoid→TSH (dose-blind, indication-blind), остальное помечено potential и не fires. Полная логика — в SPEC.md.
(Историческая справка: было 6 статусов; historical_for_trend_only убран 2026-04-30; unknown выделен из LLM-контракта в runtime-only.)
Три уровня обработки
Классификатор работает на трёх вложенных уровнях — их легко спутать (выше описана выходная схема одного вызова, а ниже — несколько уровней обёрток вокруг него):
один LLM-вызов ⊂ один батч (= вызов + валидация N результатов) ⊂ один прогон (classifyPatient — много батчей + бисекция)
- preprocess / postprocess — это про батч, не про отдельное наблюдение: один батч = N=8 наблюдений + общий контекст пациента, один вызов на батч, N классификаций обратно.
- Нарезка наблюдений на батчи по 8 — в
classifyPatient(уровень «прогон»), не внутри батча. - Демографика / активные состояния — не «вызываются» отдельно; это часть входного bundle, копируется в user-message каждого батча целиком.
Один LLM-вызов
callOnce (classifier.mastra.ts) — один robust вызов модели:
- Модель —
agentModel(modelId)(lib/model.ts): если заданLITELLM_BASE_URL— запросы идут через LLM-прокси, иначе напрямую в OpenAI. (Имя env-переменной — legacy: фактически адресует прокси, целевой прокси у нас = Bifrost; переименование — открытый пункт в llm-proxy-choice.) Primary —vertex/gemini-3-flash-preview, fallback —openai/gpt-4.1-mini.temperature: 0(детерминистично). - Чистка схемы —
cleanSchemaперед вызовом стрипает из JSON-схемы артефакты Zod/ai-sdk (additionalProperties, нестандартныеnullable-комбинации,$schema/$ref-обвязка, остатки.refine()): Vertex Gemini-3 их в structured-output режиме не понимает и отвечает мусором / зависает mid-decode / игнорирует nullability. Это класс «схема/structured-output» (см. § «Классы сбоев»); при порте, возможно, такая чистка должна жить на прокси-слое, а не в коде классификатора — не решено, см. gemini-doom-loop § «Где это инкапсулировать». - Сам вызов —
generateObject({ model, schema: BatchOutputSchemaV2, system: классификатор-промпт, prompt, temperature: 0 })(ai-sdk;BatchOutputSchemaV2 = z.object({ classifications: z.array(ClassificationSchemaV2) })). Structured-output держит форму (поля, типы, enum, порядок), но не держит надёжно.strict()/.max()/.refine()— поэтому «жёсткие» инварианты вынесены в постпроцессинг (см. «Один батч» ниже). - Таймаут и retry —
Promise.raceс таймеромVALIDITY_CALL_TIMEOUT_MS(90 s); доVALIDITY_CALL_MAX_ATTEMPTS(3) попыток на primary + 1 на fallback, и fallback включается только если последняя ошибка — retryable transport-hang (timeout/ECONNRESET/ETIMEDOUT/EAI_AGAIN/503/UNAVAILABLE/aborted/socket hang up), не 4xx и не schema-validation. - Nonce-perturbation на retry — на попытках >1 к промпту дописывается
<!-- transport-retry … nonce=… -->: меняет префикс промпта → промах prefix-кэша Vertex → другая decoder-trajectory → часто проскакивает детерминированное зависание. Модель и temperature постоянны — метрики честны (transport-retry, не model-resample). Это часть обработки транспортного класса; кандидат на вынос на прокси-слой целиком (gemini-doom-loop, llm-proxy-choice).
Carry-over: model-fallback / cascade на уровне моделей — это ровно то, что LLM-прокси bifrost делает «за код» (как Inngest для оркестрации шагов); вынос его + nonce-retry + cleanSchema на прокси (+ Langfuse-трейсинг) — часть решения по интеграции, biomarker-actuality-integration.
Один батч
classifyBatch — обработка одного батча из N наблюдений (+ общий контекст пациента): preprocess → generate → postprocess.
1. Preprocess — buildUserMessage (classifier.mastra.ts): из ValidityClassifierInput лепит текст промпта — as-of date, демографика, и два пронумерованных списка (две независимые системы индексов, обе для anti-hallucination-проверок по позиции):
- активные состояния —
activeConditionsпотомactiveMedications, пронумерованные[0] [1] …. Модель должна переписать каждую строку дословно (посимвольно, в том же порядке) вactiveStateAssessment[].state; валидатор проверяет длину массива и char-for-char совпадение по позиции — так модель не может «забыть» часть состояний пациента. - наблюдения — каждое со своим маленьким индексом
[0] analyte: "TSH" | value: … | date: …,[1] …. Модель копирует индекс обратно вobservationIndex— по нему код джойнит классификацию к исходному наблюдению (а оттуда — к FHIR Observation; FHIR id’ы в промпт не идут, детали — llm-fhir-linkback).
То есть [0] в списке состояний и [0] в списке наблюдений — разные «нулевые» элементы; обе нумерации параллельны, каждая служит своей проверке.
2. Generate — один LLM-вызов (см. § «Один LLM-вызов»): на батч из N наблюдений + контекст → N классификаций.
3. Postprocess — validateClassifierOutput(input, raw) (classifier.runtime-validate.ts) — anti-hallucination guard: проверяет то, что схема не может, и при провале заставляет повторить:
- observationIndex set-equality — ровно одна строка на каждое входное наблюдение, индексы
0..N-1, без дублей и extra. Если строк меньше входа — soft-rejection: идёт в retry-with-hint (модель «забыла» строку — подсказка может помочь). Если строк больше входа —hardRaise: модель выдумала наблюдения; retry-with-hint пропускается (повторный прогон с тем же входом, скорее всего, повторит конфабуляцию) — батч сразуsuccess:false→ транспортный путь обработки сбоя (бисекция /unknown, см. § «Один прогон»). - исчерпывающая verbatim-энумерация активных состояний —
activeStateAssessment.lengthобязан равняться#activeConditions + #activeMedications, и каждыйstate[i]посимвольно равен входной строке на той же позиции. Иначе модель тихо «забывает» часть состояний пациента, иcontextSignalsврут. Структурно обмануть нельзя — длина фиксирована входом. - кросс-полевая логическая консистентность —
lifelong_no_retestтребуетshelf === "lifelong";..._with_caveatтребует ≥1materialRelevance === "yes";currently_representativeтребует нольyes;asymptomaticExtensionApplies === "yes"требуетshelf === "metabolic"И нольyes;=== "not_applicable"противоречитshelf === "metabolic";finalStatus— из закрытого набора четырёх. analyte_echo_drift— warning, не rejection: еслиrow.analyte !== inputObs.analyte, флагается, но не блокирует (join всё равно по индексу).- На успехе — сортирует строки по
observationIndex, выводитcontextSignals.
При rejection’ах оркестратор делает один retry: rejectionsToRetryHint собирает подсказку («исправь ровно это и переэмить ВЕСЬ батч» + буллеты по каждому rejection + при length-mismatch — точный список входных состояний). Если и второй прогон фейлит валидацию — синтезируются строки finalStatus: "unknown", shelfNote: "runtime-validation-failure" (батч считается «успешным», просто с unknown-строками).
Carry-over: этот «validate → retry-with-hint → degrade» при переносе в Inngest, видимо, станет отдельным шагом (или step-retry с дополнительным input’ом — подсказкой). Где именно живёт этот цикл в Inngest-разложении — открытый вопрос интеграции, biomarker-actuality-integration § Вопрос 3.
Нюансы пустых / опциональных полей (handling частично неявный — осмыслить при порте):
unit/refRangeопциональны — если нет, в user-message просто не печатаются (код тихо опускает:obs.refRange ? " (ref: …)" : "", никакого placeholder’а). Промпт явно запрещает вводить факты, не выводимые из input’а («You must not introduce clinical facts not derivable from the input or from the steps you just emitted») — так что reference range модель в норме выдумывать не должна; отдельного guard’а именно против этого в промпте/валидаторе нет — риск гипотетический, не наблюдённый в goldset’ах (вывод из кода/промпта, не указано Артуром явно).demographics.ageYearsнет / неоднозначен → age-gate в Step 6 не проходит (продление не применяется — намеренно консервативно).note/reasoning— unbounded string’и (промпт говорит «~15 / ~20 слов», Zod длину не enforce’ит).
Один прогон (classifyPatient)
classifyPatient(input) — оркестрация всех батчей:
- Режет
observationsна батчи поVALIDITY_BATCH_SIZE(default 8), сохраняяoffsetкаждого батча для последующего реиндекса. - In-process worker-pool:
min(VALIDITY_CONCURRENCY (default 4), #batches)воркеров тянут батчи из общего счётчика,Promise.all(workers). Каждый батч →classifyBatchWithSplit→classifyBatch(см. § «Один батч») →callOnce→generateObject. - Бисекция (
classifyBatchWithSplit): если батч из N наблюдений терминально упал (брошенная transport-ошибка после всех попыток ИЛИhardRaise), N > 1 и глубина <VALIDITY_SPLIT_MAX_DEPTH(3) — батч делится пополам (mid = ceil(N/2)), каждая половина гоняется заново (та же модель/промпт/temperature, меньше payload). Зачем: обычно во всём батче есть одно «ядовитое» наблюдение (конкретная последовательность токенов детерминированно вешает Vertex Gemini mid-decode — см. gemini-doom-loop); деление пополам изолирует его до 1-obs-фейла, остальные приходят нормально. Половина-фейл → строкиfinalStatus: "unknown",shelfNote: "split-half-failure". Обе половины упали →failedBatches++,shelfNote: "batch-failure: …". - На каждый успешный батч — реиндекс
observationIndex += offset; на failed-батч — строкиunknown(batch-failure). Failed-батч не валит весь прогон — partial-результаты возвращаются, caller инспектируетfailedBatches/retriedBatches. - Собирает
PatientValidityProfile { asOfDate, modelId, promptVersion, classifications }+{ totalBatches, failedBatches, retriedBatches, warnings }.
Этот worker-pool + рекурсивная бисекция + ручной retry-loop в callOnce — самодельная in-app очередь / concurrency / retry-machinery; для нас это анти-паттерн (no-self-rolled-queues) — при порте всё это переразлагается на Inngest-шаги (что такое шаг vs функция — inngest § Step vs Function; конкретные варианты декомпозиции — biomarker-actuality-integration § Вопрос 3).
Константы
Все env-override; defaults:
| Константа | Default | Что |
|---|---|---|
VALIDITY_BATCH_SIZE | 8 | наблюдений на один LLM-вызов (shared patient-context + N наблюдений → N классификаций). ≈ ceil(N/8) вызовов в happy path |
VALIDITY_CONCURRENCY | 4 | сколько батчей бежит параллельно (in-process semaphore, без backpressure) |
DEFAULT_VALIDITY_MODEL | vertex/gemini-3-flash-preview | primary-модель |
VALIDITY_FALLBACK_MODEL_ID | openai/gpt-4.1-mini | fallback-модель |
VALIDITY_CALL_TIMEOUT_MS | 90 000 | таймаут на один generateObject (Promise.race с таймером) |
VALIDITY_CALL_MAX_ATTEMPTS | 3 | попыток на primary; +1 на fallback только если последняя ошибка — retryable transport-hang |
VALIDITY_SPLIT_MAX_DEPTH | 3 | максимальная глубина рекурсивной бисекции |
temperature | 0 | детерминистично |
(maxOutputTokens в generateObject не задан — дефолт модели; стоит выставить явно, см. gemini-doom-loop § Открытые вопросы.)
Классы сбоев
У одного LLM-вызова / батча сбои делятся на три класса, и для каждого правильный слой обработки разный (общая таксономия — llm-call-failure-classes):
- Транспорт — Gemini завис mid-decode / timeout /
503/ECONNRESET/ socket hang up. Обработка здесь: retry до 3× + nonce-perturbation → fallback наopenai/gpt-4.1-mini;hardRaise(модель выдумала наблюдения) тоже идёт по этому пути — retry-with-hint против конфабуляции бесполезен → сразуsuccess:false→ бисекция →unknown. → инфра-слой; кандидат уехать на прокси (llm-proxy-choice, gemini-doom-loop). - Схема / structured-output — Vertex криво держит structured-output на strict-nullable / schema-артефактах. Обработка здесь:
cleanSchemaстрипает артефакты до вызова (превентивно, в control-flow-диаграмме не виден). → тоже инфра/прокси. - Семантика / контент — модель «забыла» состояние / поля не консистентны /
analyte-echo дрейфует / выдумала наблюдения. Обработка здесь:validateClassifierOutput→ reject → один retry с прицельной подсказкой (rejectionsToRetryHint) → degrade tounknown-строки. → app-level, не выносится: прокси не знает, что для validity «консистентно».
Все три дают partial-результат (батч с unknown-строками считается «успешным»), прогон не валится — caller инспектирует failedBatches / retriedBatches.
Зависимости / самодостаточность
classifier.mastra.ts импортирует только: @repo/analysis-validity (типы + валидатор + BatchOutputSchemaV2), ai (generateObject), lib/model (agentModel), fs/path/url (читает classifier.md с диска). Сервис и input-builder — поверх этого + parameter-analysis.types (для shape V2PatientContext). Никакой связи с code-retriever / diagnostic-graph-tools / parameter-analysis.service — переносимо в изоляции; upstream FHIR-сбор у нас уже есть (buildV2PatientContextFromFhir). Как именно выглядит порт (что вызвать, что протащить — fhirObservationId в LatestObservation, что переразложить на Inngest, что вынести на прокси) — это уже сшивка, см. biomarker-actuality-integration.
Имя файла врёт: classifier.mastra.ts назван по mastra, но Mastra в нём нет — это голый generateObject из ai-sdk + agentModel. (Сам по себе файл на ветке Артура работает правильно — import.meta.url-чтение classifier.md корректно, пока он запускается из tsx script.ts. Port-gotcha — что под CJS-бандлом analysis-worker import.meta.url undefined и top-level fileURLToPath крэшнет — это вопрос интеграции, расписан в biomarker-actuality-integration § Следствия.)
Где в коде
| Артефакт | Расположение (origin/feat/v2-5) |
|---|---|
| LLM-оркестрация (один батч) | packages/analysis-core/src/agents/validity-classifier/classifier.mastra.ts — callOnce, classifyBatch, classifyBatchWithSplit, buildUserMessage |
| Публичный orchestrator | packages/analysis-core/src/services/validity-classifier.service.ts — classifyPatient, чанкинг, worker-pool |
| Input-builder | packages/analysis-core/src/services/validity-input-builder.ts — buildValidityInput, summarizeCondition, latest-per-analyte |
| Промпт (~444 строки) | packages/analysis-core/src/prompts/validity-classifier/classifier.md — guardrails, 9 шагов, shelf reference, active-biology test, predates-driver, 5 worked examples, batch contract, pre-emit checklist |
| Типы + Zod-схемы | packages/analysis-validity/src/classifier.types.ts |
| Runtime-валидатор | packages/analysis-validity/src/classifier.runtime-validate.ts — validateClassifierOutput, rejectionsToRetryHint |
| Синтетический stub (не классификатор) | packages/analysis-validity/src/{index,types,stub-table}.ts — getValidityForDomain, assertNoSyntheticInProd, STUB_TABLE (всё unused) |
| Eval-suite | packages/analysis-core/eval/validity/ — SPEC.md (~42 KB, контракт логики), README.md, DEBUG_METHODOLOGY.md, T1..T17_*.md (журнал итераций промпта), fixtures/*.json (~38 голдсет-пациентов с expected-блоком: healthy / single-condition / multimorbidity / assay-interference / therapy-change-timing / age-gating / pre-diagnosis) |
| CLI/eval-скрипты (единственные вызывающие) | packages/analysis-core/scripts/{test-validity-classifier,run-validity-eval,run-validity-consistency,gen-validity-clinician-md}.ts |
| Модель-резолвер | packages/analysis-core/src/lib/model.ts (agentModel, cleanSchema) |
| Prisma-схема под output | нет. Миграция 20260423214744_add_v2_5_rich_output_fields добавила triage/foundContext/missingContext/referenceContext/additionalWorkup/citations/whatAdditionalDataWouldClarify в ParameterAnalysis — для validity ничего |
Наш стек (feat/v2-5-on-staging) | packages/analysis-core/src/services/patient-context-from-fhir.ts (buildV2PatientContextFromFhir), parameter-analysis.service.ts, agents/parameter-analysis/{diagnostician,retriever}.agent.ts, lib/{fhir-clinical-impression-builder,prisma-batch}.ts, apps/analysis-worker/.../enrichment/enrich-fhir-observations.function.ts |
| Branch / автор | feat/v2-5 (Артур; 93 коммита впереди staging, отпочкована от той же точки e7a4a18f Apr 22, что и feat/v2-5-on-staging) |
Диаграммы
ASCII-черновики; polished draw.io-рендеры можно сделать через /diagram позже.
D1 — control flow одного прогона
В D1 видны два из трёх классов сбоев батча (третий, схема, обрабатывается превентивно cleanSchema-ом до вызова и в control flow не показан): (1) семантический — validateClassifierOutput отклонил вывод → один callOnce(retry) с подсказкой → если и retry не прошёл, строки finalStatus: "unknown" (runtime-validation-failure); (2) транспортный — generateObject бросил после всех попыток (timeout/ECONNRESET/503) ИЛИ hardRaise (выдуманные наблюдения) → success:false → бисекция → если и она не помогла, строки unknown (split-half-failure / batch-failure). Подробнее — § «Классы сбоев». Оба runtime-пути дают partial-результат.
classifyPatient(input)
│ observations → chunk(VALIDITY_BATCH_SIZE=8) → [{items, offset}, ...]
│
├─ worker-pool: min(VALIDITY_CONCURRENCY=4, #batches) воркеров, общий next++
│ │ [as-is: in-process — анти-паттерн; target: Inngest fan-out]
│ ↓ на каждый батч:
│ classifyBatchWithSplit(batch, depth=0)
│ │
│ ├─ classifyBatch(batch)
│ │ │ preprocess: buildUserMessage (2 пронумерованных списка: состояния / наблюдения)
│ │ │ generate: callOnce → cleanSchema → generateObject(schema=BatchOutputSchemaV2, temp=0)
│ │ │ ×(≤3 primary +1 fallback), Promise.race(timeout=90s),
│ │ │ nonce-perturb на retry, fallback только на retryable transport err
│ │ │ postprocess: validateClassifierOutput(input, out) [(1) семантический путь]
│ │ │ ├─ ok ───────────────────────────────→ classifications ✓
│ │ │ ├─ hardRaise (#rows > #obs) ──────────→ success:false ──┐ (retry-with-hint пропущен)
│ │ │ └─ soft rejections ──→ rejectionsToRetryHint → callOnce(retry) → validate
│ │ │ ├─ ok ────────────────────────→ classifications ✓ (retried)
│ │ │ └─ still fails ────────────────→ synthesize finalStatus:"unknown" (runtime-validation-failure)
│ │ └─ thrown transport error after retries ───→ success:false ──┤ [(2) транспортный путь]
│ │ │
│ ├─ if success:false & len>1 & depth<VALIDITY_SPLIT_MAX_DEPTH=3: ←──┘
│ │ бисекция: Promise.all([recurse(left), recurse(right)])
│ │ half ok → merge (right reindexed +mid); half fails → "split-half-failure" unknown rows
│ └─ else: return result (1-obs failure or depth exhausted → "batch-failure" unknown rows)
│
│ per batch: success → reindex observationIndex += offset; fail → "batch-failure" unknown rows
↓
PatientValidityProfile { asOfDate, modelId, promptVersion, classifications: batchResults.flat() }
+ { totalBatches, failedBatches, retriedBatches, warnings } [→ persistence: TBD]
D2 — модель данных
FHIR (Google Healthcare API): Patient + Observation×(many, per period) + Condition + MedicationStatement
│ buildV2PatientContextFromFhir(fhir, patientId) [уже есть на нашей ветке]
↓
V2PatientContext { demographics, abnormalObservations[], normalObservations[], activeConditions[], activeMedications[] }
│ V2Observation = { name, fhirObservationId?, loincCode?, value, unit?, referenceRange?, interpretation?, date?, ... }
│ └─ fhirObservationId УЖЕ есть на feat/v2-5-on-staging (заполняется в buildV2PatientContextFromFhir)
│ buildValidityInput(ctx, asOfDate) — latest-per-analyte dedup (loinc || lowercased name), drop no-date/no-value
│ └─ РОНЯЕТ fhirObservationId (на ветке Артура); при порте — протащить
↓
ValidityClassifierInput { asOfDate, patientContext{demographics, activeConditions[]:string, activeMedications[]:string}, observations[]:LatestObservation }
│ LatestObservation = { analyte:string (display-only), value, unit?, date, refRange? } ← join-ключ = позиционный индекс, не id
↓ classifyPatient → (batches of 8 → LLM, schema=ClassificationSchemaV2) → validate → derive contextSignals
PatientValidityProfile { asOfDate, modelId, promptVersion, classifications[] }
│ ValidityClassification (per observation):
│ observationIndex, analyte,
│ shelfClassification ∈ {acute, metabolic, lifelong, unclear}, shelfNote,
│ activeStateAssessment[] = [ {state (verbatim), materialRelevance ∈ {yes,no}, note}, ... ] (len = #conditions + #meds)
│ asymptomaticExtensionApplies ∈ {yes, no, not_applicable}, extensionNote,
│ finalStatus ∈ {currently_representative, currently_representative_with_caveat, likely_outdated, lifelong_no_retest, (unknown=runtime-only)},
│ reasoning,
│ contextSignals[] = activeStateAssessment.filter(yes).map(state) [derived in code]
↓
[??? — куда персистится / кто читает: TBD — нет Prisma-колонок, нет FHIR-host, нет consumer'а; см. decisions/...-pipeline-integration]
Где validity сидит в нашем пайплайне (диаграмма D3 — было здесь, переехало) — это уже сшивка, не внутрянка: biomarker-actuality-integration § Вопрос 1 (gate перед параметр-анализом; patientSummaryPipeline через событие analysis/patient-summary.requested; разница с legacy interpret/:testId; disambiguation с StagedEnrichmentService).
Открытые вопросы
К Артуру (внутренности классификатора):
- Что
@repo/analysis-validity-stub вообще делает (и зачем в одном пакете с типами классификатора)?getValidityForDomainпредполагается использовать или это scaffolding? Каков реальный заменитель stub-данных? Нужен ли stub нам для порта, или его роль может закрыться eval-фикстурами? - Имена выходных полей (
shelfClassification,shelfNote,asymptomaticExtensionApplies,extensionNote,materialRelevance) — settled или можно переименовать? (⊥ health-report-vocabulary.) - Какая модель — production-target: именно
vertex/gemini-3-flash-preview, или то, на что роутит bifrost (и нужен ли re-tune промпта)? (⊥ gemini-flash-vs-pro-allocation.) classifier.mastra.ts— должен использовать Mastra (и пока нет), или имя вестигиально?- Где живут 38 eval-фикстур + harness после порта —
eval/validity/как есть, или сложить в eval-judge cron? - Известные слабые места логики промпта (open questions §5 Q1–Q4 в
SPEC.md)? - Видел ли он на eval-прогонах транспортные сбои / усечённый output Gemini / семантик-reject’ы валидатора — и как часто? Есть ли счётчики? (Нужно понять, какие из обработок-сбоев — рабочая лошадка, а какие — страховка под редкий кейс; см. ниже про бисекцию.)
Прочее:
- Терминология — выбрать один канонический термин: ветка мешает «валидность» (имя пакета/dir/промпта), «репрезентативность» (формулировка
finalStatus-статусов), «релевантность»; плюсanalyte/ биомаркер / параметр / Observation для одной сущности. Эта страница пока называет это «валидностью» по коду. (⊥ health-report-vocabulary.) maxOutputTokensвgenerateObjectне задан явно — выставить, см. gemini-doom-loop.- Частота сбоев каждого класса — нет инструментирования: непонятно, как часто реально срабатывают (а) транспортные зависания / усечённый-мусорный output Gemini (→ retry / nonce-perturb / fallback / бисекция), (б) семантик-reject валидатором (→ retry-with-hint), (в)
hardRaise(модель выдумала наблюдения). Без этих чисел не видно, что из обработки-сбоев — рабочая лошадка, а что — страховка под редкий кейс. Carry-over: Langfuse — для recognizelf.tryreal.techprojectcmbc586n7004ruh07l25slrud; для validity трейсов пока нет (бежит только из CLI/eval). - Насколько обязателен путь с бисекцией? Бисекция отравленного батча (
classifyBatchWithSplit) — единственный механизм точечной изоляции «ядовитого» наблюдения, но это data-dependent рекурсия (плохо ложится на Mastra-граф — mastra § Workflows) и требует самодельного worker-pool (no-self-rolled-queues). Если транспортные/усечённые сбои редки (см. вопрос выше) — альтернативы: (a) без бисекции — меньшие батчи + retry-with-hint + «не вышло за N попыток → весь батч вunknown-статус» (теряем точечную изоляцию, принимаем редкие N-obs hard-fail’ы); (b) бисекция как императивный код внутри одного Inngest-шага (точность есть, но шаг непрозрачен для визуализации); (c) worklist +.dowhile(бисекция видна как рост очереди, но последовательно — теряется concurrency). Где это решается — biomarker-actuality-integration § Вопрос 3.
(Что класть в asOfDate — уже решено, см. § I/O-контракт / biomarker-actuality-integration § Позиции — это решение по интеграции, не открытый вопрос.)
Решения по интеграции — это работа Ильдара (где validity сидит в пайплайне, куда уходит output, как раскладывается на Inngest-шаги, нужна ли FHIR-id-прокидка, стыковка с TTL-фильтром, влияние на triage). Все они — в biomarker-actuality-integration, здесь не дублирую.
Связано
- biomarker-analysis-pipeline — соседний V2.5-пайплайн (другой вопрос — «что значит отклонение», не «валидно ли значение»); делит
V2PatientContext - llm-fhir-linkback — как LLM-вывод линкуется обратно к FHIR Observation (индекс у validity / имя у per-параметр-анализа); там же gap про
LatestObservationбезfhirObservationId - structured-output-field-order-cot — приём «порядок полей схемы = chain-of-thought», на котором построен 9-шаговый CoT
- llm-call-failure-classes — общая таксономия классов сбоев LLM-вызова (транспорт / схема / семантика) и кто какой берёт; validity — носитель всех трёх
- llm-call-granularity — паттерн «1 / N / батчами» + трёхуровневая декомпозиция; validity — каноничный носитель
- structured-llm-service-page-template — мета-шаблон страницы для подобного сервиса (по образцу этой страницы)
- gemini-doom-loop — зависание Vertex/Gemini mid-decode + три обхода (бисекция / nonce-perturbation /
cleanSchema), которые здесь и живут - single-prompt-analysis — третий подход к интерпретации параметра
- patient-summary — patient-summary pipeline, в который валидность встраивается
- mastra — фреймворк;
classifier.mastra.tsназван по нему, но не использует - bifrost / llm-proxy-choice — LLM-прокси; cascade-fallback / nonce-retry / cleanSchema на уровне моделей логично вынести туда вместо ручного
callOnce-loop (как Inngest для оркестрации шагов) - inngest — orchestrator; целевая площадка для шагов классификатора; § Step vs Function
- fhir-observation — FHIR-ресурс, к которому линкуются результаты валидности
- biomarker-actuality-integration — где в пайплайне (gate перед анализом) + куда output (скорее ephemeral) + Inngest-декомпозиция + всё остальное по интеграции
- no-self-rolled-queues — почему worker-pool + рекурсивная бисекция
classifyBatchWithSplitне портируются как есть - biomarker-actuality-thresholds — детерминистический TTL-фильтр актуальности; validity-классификатор его расширяет
- interpretation-scope-patient-vs-test — валидность пациент-scoped, как и V2.5
- staged-output-fhir-storage — куда V2.5 rich-output ложится в FHIR (смежно)
- health-report-versioning-model — POST-vs-PUT Composition (смежно с персистентностью per-run)
- health-report-vocabulary — несогласованность имён стадий; сюда же
.mastra-misnomer и терминология - gemini-flash-vs-pro-allocation — flash-vs-pro; validity на flash-preview