Status: superseded. Эта страница — снимок прежней v2 standalone-архитектуры validity-классификатора на ветке Артура feat/v2-5 HEAD 0da5fc41 (батчинг по 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.)

Два слоя, которые легко спутать:

  1. LLM-классификатор — то, что описывает эта страница. Production-grade рассуждение, но по дизайну ещё не подключён на feat/v2-5: нет Inngest-функции, нет персистентности, PatientValidityProfile пока вызывается только из CLI/eval-скриптов — отдельный, готовый к интеграции модуль.
  2. Пакет @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.
  • demographicsageYears / 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.

#ПолеТипЧто решаетЧто НЕ смотрит
1observationIndexintКопия индекса из входа (для join обратно).
2analytestringVerbatim-копия имени аналита (только для human review).
3shelfClassificationenum: 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 это свойство аналита, не пациента.
4shelfNotestring (~20 слов)Одна фраза про биологию аналита, которая поместила его на этот shelf.Контекст пациента.
5activeStateAssessment[]array of { state: string, materialRelevance: enum yes|no, note: string }Anti-tagging gate. Исчерпывающая энумерация каждого элемента activeConditions + activeMedications (conditions первыми в input-порядке, потом medications). Длина массива = activeConditions.length + activeMedications.length, обе пустые → []. Каждый элемент: state — verbatim-копия строки посимвольно; materialRelevanceyes ⟺ знающий клиницист изменил бы, как читает ИЛИ как часто пере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, которого нет во входе.
6asymptomaticExtensionAppliesenum: yes | no | not_applicableПрименяется ли ~13-месячное asymptomatic-screening продление к этому аналиту: yes ⟺ shelf metabolic + асимптомный взрослый ~18–75 + ни одной materialRelevance:"yes" записи; no = metabolic, но один gate отключил продление (педиатрия / frail elderly 75+ / есть драйвер); not_applicable = shelf не metabolic.
7extensionNotestring (~20 слов)Одна фраза с причиной gating.
8finalStatusenum (4 значения)Вердикт — из закрытого набора четырёх (см. § «Статусов — 4 + 1» ниже: значения, дерево решения, predates-driver-триггер). Должен быть логически консистентен с предыдущими полями — проверяется валидатором.
9reasoningstring (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 yescurrently_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() — поэтому «жёсткие» инварианты вынесены в постпроцессинг (см. «Один батч» ниже).
  • Таймаут и retryPromise.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. PreprocessbuildUserMessage (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. PostprocessvalidateClassifierOutput(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 требует ≥1 materialRelevance === "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). Каждый батч → classifyBatchWithSplitclassifyBatch (см. § «Один батч») → callOncegenerateObject.
  • Бисекция (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_SIZE8наблюдений на один LLM-вызов (shared patient-context + N наблюдений → N классификаций). ≈ ceil(N/8) вызовов в happy path
VALIDITY_CONCURRENCY4сколько батчей бежит параллельно (in-process semaphore, без backpressure)
DEFAULT_VALIDITY_MODELvertex/gemini-3-flash-previewprimary-модель
VALIDITY_FALLBACK_MODEL_IDopenai/gpt-4.1-minifallback-модель
VALIDITY_CALL_TIMEOUT_MS90 000таймаут на один generateObject (Promise.race с таймером)
VALIDITY_CALL_MAX_ATTEMPTS3попыток на primary; +1 на fallback только если последняя ошибка — retryable transport-hang
VALIDITY_SPLIT_MAX_DEPTH3максимальная глубина рекурсивной бисекции
temperature0детерминистично

(maxOutputTokens в generateObject не задан — дефолт модели; стоит выставить явно, см. gemini-doom-loop § Открытые вопросы.)

Классы сбоев

У одного LLM-вызова / батча сбои делятся на три класса, и для каждого правильный слой обработки разный (общая таксономия — llm-call-failure-classes):

  1. Транспорт — 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).
  2. Схема / structured-output — Vertex криво держит structured-output на strict-nullable / schema-артефактах. Обработка здесь: cleanSchema стрипает артефакты до вызова (превентивно, в control-flow-диаграмме не виден). → тоже инфра/прокси.
  3. Семантика / контент — модель «забыла» состояние / поля не консистентны / analyte-echo дрейфует / выдумала наблюдения. Обработка здесь: validateClassifierOutput → reject → один retry с прицельной подсказкой (rejectionsToRetryHint) → degrade to unknown-строки. → 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.tscallOnce, classifyBatch, classifyBatchWithSplit, buildUserMessage
Публичный orchestratorpackages/analysis-core/src/services/validity-classifier.service.tsclassifyPatient, чанкинг, worker-pool
Input-builderpackages/analysis-core/src/services/validity-input-builder.tsbuildValidityInput, 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.tsvalidateClassifierOutput, rejectionsToRetryHint
Синтетический stub (не классификатор)packages/analysis-validity/src/{index,types,stub-table}.tsgetValidityForDomain, assertNoSyntheticInProd, STUB_TABLE (всё unused)
Eval-suitepackages/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 — для recognize lf.tryreal.tech project cmbc586n7004ruh07l25slrud; для 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

Источники