LLM-агент, единственный код-консьюмер biomarker-graph в текущей архитектуре. Первый шаг biomarker-analysis pipeline (biomarker-analysis-pipeline): Diagnostician → Retriever → Reasoner → Writer / Personalizer. Получает контекст пациента, ходит в граф через tool-calls, возвращает структурированный «план обогащения» (DiagnosticPlan) — список биомаркеров с указанием, какие связанные параметры / диагнозы / лекарства / преаналитические факторы стоит достать из FHIR. Сам по себе не диагностирует и не интерпретирует — это knowledge-planner, не «диагностик» в клиническом смысле.

Carry-over: имя «Диагностик» / «Diagnostician Agent» исторически унаследовано из Python-порта, но не отражает функции (планирует обогащение, не диагностирует). Кандидаты на переименование: KnowledgePlanner, EnrichmentPlanner, RelationsPlanner. Не делаем сейчас — есть зависимости в коде + промптах; вынести в отдельный naming-decision когда дойдут руки.

С появлением сервиса актуальности Диагност нужен и ему: план обогащения используется Ретривером для построения relatedFound*, который классификатор актуальности читает как retrieved per биомаркер (см. biomarker-actuality-integration). То есть Диагност обслуживает двух потребителей — классификатор актуальности (на входе gate’а) и downstream-анализ (Reasoner/Writer на выходе).

Концептуально — связи между сущностями пациента

В медицинской записи пациента лежит список сущностей: наблюдения, диагнозы, рецепты, измерения биомаркеров. Сами по себе они — плоский набор фактов без связей. Но медицина строится вокруг связей: «этот биомаркер сдвигается этим препаратом», «это значение надо читать в свете того диагноза», «эти два показателя вместе указывают на проблему той системы».

Диагност из своих знаний (закодированных в biomarker-graph) говорит: «для биомаркера X с такими-то сущностями может быть связь — если у пациента есть состояние A или препарат P, биомаркер нужно читать в их контексте». Это указание возможных узлов, в свете которых биомаркер должен интерпретироваться, если эти узлы у пациента действительно присутствуют.

Дальше Ретривер идёт в FHIR и проверяет существование узлов у конкретного пациента. Если узел есть — биомаркер надо рассматривать в свете этой сущности; если узла нет — рассматривать в её свете не нужно, она к этому пациенту не относится.

Деление труда. Диагност+Ретривер вместе превращают плоский список сущностей пациента в граф связей, по которому downstream-стадии (классификатор актуальности, Reasoner) могут судить о биомаркере. Диагност — knowledge-source (какие связи бывают в принципе, из медицинских знаний). Ретривер — node-check (какие из этих узлов реально есть у этого пациента, из его FHIR). LLM нужен на стороне знаний о связях; проверка узлов в FHIR — детерминистическая операция.

Терминология рабочая — слова «гипотеза», «верификация» в коде не используются. Концептуально это hypothesis-and-check паттерн (Диагност предлагает кандидатов на связь, Ретривер проверяет их наличие), но окончательные имена ролей пока не зафиксированы.

Каскад Tier 1/2/3

На каждом аномальном наблюдении задача одинаковая — собрать related_* (связанные параметры / диагнозы / лекарства / преаналитические факторы), чтобы Ретривер потом проверил их наличие в FHIR. Меняется только источник этих related_* — отсюда три уровня (tier’ы):

TierevidenceTierИсточник related_*Трассируемость
1algorithmспецифичный biomarker-entry в графеquotes / source per ребро
2domain_inferencedomain-level дефолты в графеtrace до domain-записи
3model_knowledgeобщие медицинские знания LLMнет graph-trace

Названия value’ов отражают способ резолва: algorithm = детерминистическое попадание в конкретную запись, domain_inference = inference через домен, model_knowledge = генерация из LLM-знаний без графа. (Терминология рабочая — наследие Python-порта, потенциально пере-именуется когда Tier 1/2 уйдут на детерминистический lookup.)

Шаг 1 (Tier 1). Резолвится по имени в biomarkers[…]. Domain известен из поля entry.domain (biomarkers["Креатинин"].domain === "renal"). Сегодня попадание в Tier 1 — это LLM-fuzzy-match: имена из лабораторных отчётов («Холестерин общий») не совпадают с keys графа («Total cholesterol»), и LLM перебирает синонимы. При замене ключа графа на LOINC (biomarker-graph-key-loinc) этот шаг стал бы детерминистическим dict.get(loincCode).

Шаг 2 (Tier 2). Если биомаркера в biomarkers[…] нет — определяется его домен, берётся domain-level related_* из domains[domain]. Выбор домена сегодня делает LLM, читая scope-описания доменов. Сделать его deterministic можно через внешнюю классификацию (LOINC→domain сервис), см. biomarker-graph-key-loinc.

Шаг 3 (Tier 3). Если ни биомаркера, ни домена не нашли — LLM генерирует related_* «из общих медицинских знаний». Нежелательный fallback (нет трассируемости), но нужен для биомаркеров вне графа.

Tier как сигнал доверия. Tier 1 > Tier 2 > Tier 3. Downstream-стадии (Reasoner, классификатор актуальности) видят эту метку и могут реагировать — не surface’ить caveat’ы для Tier 3 связей, или явно отмечать пациенту, что связь «из общих знаний», а не из guideline’а. Заполненность Tier 1 vs скатываний на Tier 2/3 — прямая метрика покрытия графа.

Tools

Три инструмента, проброшенные в LLM:

Tools как LLM-обёртка над функциями. Концептуально каждый из этих трёх tools — это функция, которую при детерминистическом lookup’е мы могли бы вызывать сами, напрямую из кода. Сейчас LLM вызывает их за нас, потому что fuzzy-match по именам / domain inference сам по себе ещё не детерминистический. Если Tier 1/2 уедет на LOINC-lookup (biomarker-graph-key-loinc) — tools деградируют до простых function calls в pipeline-коде, агентский цикл LLM уходит. Это конкретный callsite третьей оси — когда LLM в pipeline вообще нужен, и как pipeline эволюционирует от LLM-dispatched к deterministic-dispatched по мере того, как underlying-операции становятся решёнными.

  1. lookup_biomarker(name) — поиск одного биомаркера в графе. Возвращает полные данные (domain, related_*, preanalytical_factors) если найден; иначе — список всех доступных biomarker-ключей (это и есть «не подсказывай, но дай выбор» — LLM сам решит, попробовать ли другой ключ или fallback на домен). Поддерживает batching (параллельные вызовы).
  2. lookup_domain(domain) — Tier 2 fallback. Когда биомаркер сам по себе в графе отсутствует — резолвится через свой домен (hematology, thyroid, lipid, …) и тянет domain-level related_*.
  3. submit_diagnostic_plan(plan) — терминальное действие. Завершает агентский цикл. Для Tier 1 / Tier 2 элементов LLM не дублирует graph-данные в payload’е — DiagnosticGraphExecutor сам впрыснет related_* из кэша lookup’ов по graph_biomarker_key / graph_domain. Для Tier 3 (model_knowledge) наоборот: впрыскивать нечего, LLM сам обязан положить related_* в payload.

DiagnosticGraphExecutor — stateful per-run (кэш lookup’ов, флаг isFinished после успешного submit’а, счётчик rejection’ов submit’а). Один инстанс на один runDiagnosticianAgent-вызов.

Как это работает

runDiagnosticianAgent(patientContext)
  │  abnormal_observations пуст → success: true, empty plan, 0 calls
  ├─ loadBiomarkerGraph() — lazy-кэш JSON в памяти (см. [[biomarker-graph]])
  ├─ new DiagnosticGraphExecutor(graph, contextDict) — stateful per run
  ├─ Mastra Agent(systemPrompt + 3 tools, maxSteps=15)
  ├─ buildUserMessage(contextDict, executor) — XML-блоки <patient_context>, <knowledge_graph_index>
  ↓  agentic loop:
     1. LLM batches lookup_biomarker(...) параллельно по всем abnormal-observations
     2. промахи → lookup_domain(...) Tier 2
     3. промахи и здесь → Tier 3 (model_knowledge), related_* пишутся в payload submit'а
     4. submit_diagnostic_plan(plan) → executor валидирует input parity → isFinished=true
  ↓
{ success: true, plan: DiagnosticPlan, toolCallsCount, iterations }

Время работы и стоимость

Cost-measurement framework (Latency p50/p95, token cost input/output, retry / failure rate, phase breakdown) — общий для всех LLM-стадий pipeline’а: Diagnostician, Retriever, классификатор актуальности, Reasoner, Writer-doctor. Описание framework’а вынесено в llm-stage-cost-measurement (placeholder — заполнить при первом систематическом замере). Здесь — Diagnostician-специфика.

Сегодня замеров на проде нет. В отладочных запусках разброс от ~1 минуты до 5+ минут на одном и том же пациенте при том же объёме данных — Gemini-3.1-pro reasoning time нестабилен. До систематических замеров любые выводы остаются гипотетическими.

Что хочется знать про Диагноста специально:

  • Phase breakdown — сколько runtime / token cost уходит в Tier 1 fuzzy-match vs Tier 2 domain selection vs Tier 3 generation. Это определяет приоритет оптимизаций: Tier 1 уходит на LOINC-ключ (biomarker-graph-key-loinc), Tier 2 — на LOINC→domain сервис (там же), Tier 3 — единственный residual где LLM незаменим.
  • Количество шагов агентского цикла. MAX_STEPS ограничивает сверху — 15 в Mastra-варианте, 25 в raw (комментарий «Gemini occasionally flips into sequential mode на больших пациентах»). По существу хватает 2-3 итераций. Частота попадания в sequential-режим — часть того же cost-breakdown’а на Langfuse-трейсах.

Источник данных — Langfuse-трейсы прод-нагрузки.

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

ВходPatientContext (бывш. V2PatientContext до Phase A rename):

  • demographics (age, sex)
  • abnormalObservations[] — отклонения, ради которых вообще запускаем анализ (с name, loinc_code, value, unit, reference_range, interpretation, date, standardized_name, loinc_class)
  • normalObservationNames[] — имена лабораторных биомаркеров со значением «в норме» (в промпт идут только имена, без значений — это контекст «что у пациента вообще измерялось»)
  • activeConditions[] — активные диагнозы (name + onset)
  • activeMedications[] — текущие препараты (name + dosage)

ВыходDiagnosticPlan:

type DiagnosticPlan = {
  parameterAssessments: ParameterAssessment[];  // ровно один элемент на каждое abnormal observation (input parity)
};
type ParameterAssessment = {
  parameterName: string;
  interpretation: string;     // H/L/A — verbatim copy от входа, никогда не пересчитывается
  clinicalDomain: string;
  evidenceTier: "algorithm" | "domain_inference" | "model_knowledge";
  graphBiomarkerKey?: string | null;        // Tier 1 — биомаркер найден в графе
  graphDomain?: string | null;              // Tier 2 — домен найден, биомаркер нет
  relatedParameters:     RelatedItem[];
  relatedConditions:     RelatedItem[];
  relatedMedications:    RelatedItem[];
  preanalyticalFactors:  RelatedItem[];
  reasoning: string;
};
type RelatedItem = { name; rationale; quotes?; source? };

Контракт input parity — один input → одна assessment-запись. Пропустить отклонение «потому что нерелевантно» запрещено; добавить новое — тоже. Пустой abnormal_observations → ранний выход с пустым планом (без LLM-вызовов).

Failure modes

  • Не сабмитнул план за MAX_STEPSsuccess: false, plan: null, error: "Agent did not submit a diagnostic plan within max steps." Caller обязан это обработать (сейчас в пайплайне — фейлит весь параметр-анализ для пациента).
  • Mastra/ai-sdk артефакты ломают prompttemperature=0, $schema поля в request, anyOf+null обёртки, additionalProperties: false. Это причина существования diagnostician.raw.ts (тот же агент, но без Mastra-обёртки, через сырой openai-клиент).
  • CJS-bundle + import.meta.urldiagnostician.mastra.ts резолвит промпт через fileURLToPath(import.meta.url). Под analysis-worker (esbuild strict-mode) import.meta.url undefined → краш. Та же готча, что и у biomarker-actuality-service.
  • Sequential-режим Gemini — недетерминированный регресс, обход через MAX_STEPS=25 в raw-варианте.

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

  • Возвращение на Mastra-вариант. Сегодня в проде работает diagnostician.raw.ts (raw OpenAI SDK без Mastra). Хочется вернуться на Mastra-вариант (observability через Mastra Studio, единая обвязка для всех агентов), но он ломался под schema-инъекциями Mastra/ai-sdk (детали в «Где живёт» ниже). Открытое — когда обвязка станет надёжной (вероятно после зрелого bifrost / fallback-слоя), переключиться обратно и убрать raw.
  • maxOutputTokens не задан — та же проблема, что и у biomarker-actuality-service: открытое окно для mid-decode timeout (gemini-doom-loop).
  • Мониторинг Tier 3 (model_knowledge) — инструментирование «пропусков графа». Каждый раз когда Диагност попадает в Tier 3 — это сигнал «графа не хватило для этого биомаркера»; такой counter (per биомаркер / per домен) даёт прямую картину coverage-дыр графа (см. biomarker-graph — coverage map). Сегодня этого инструментирования нет, и непонятно даже частота — Tier 3 = «LLM в runtime решает поверх своих знаний» = ровно тот случай, когда нам особенно нужна верификация.

Вопросы про cost-breakdown и про возможность перевода Tier 1 / Tier 2 на детерминистический lookup — в секции «Время работы и стоимость» выше, плюс на параллельных decision-страницах: biomarker-graph-key-loinc (LOINC-key для Tier 1 + LOINC→domain сервис для Tier 2), biomarker-graph-storage (production-форма графа в целом).

Где живёт

В коде сосуществуют два варианта реализации Диагноста:

  • Mastra-вариант (packages/analysis-core/src/agents/biomarker-analysis/diagnostician.mastra.ts) — runDiagnosticianAgent(...). Использует Mastra Agent обёртку поверх ai-sdk; даёт observability через Mastra Studio.
  • Raw-вариант (diagnostician.raw.ts) — runDiagnosticianRaw(...). Идёт мимо Mastra, напрямую через сырой openai SDK.

Почему два варианта. Mastra-обёртка под определёнными комбинациями inputs ломала prompt-совместимость с Python-портом, который был источником этой реализации:

  • принудительно ставила temperature: 0 — Python работал на default’е (1.0), Gemini reasoning-модели под temperature: 0 уходят в loop’ы
  • инжектировала $schema поле в JSON-schema запроса — Vertex такого не ждёт
  • оборачивала optional поля как anyOf+null вместо обычного Optional — формально валидно, но Vertex-side validator реагировал по-другому
  • ставила additionalProperties: false — слишком рестриктивно для Python-style schema

Raw-вариант все эти инъекции обходит, идя прямым OpenAI-клиентом. Это не «лучшая реализация» — это рабочая альтернатива на случай, когда Mastra ломала контракт промпта. В проде сегодня работает raw; вернуться на Mastra-вариант хочется (унифицированная обвязка, observability через Mastra Studio), но требует зрелой bifrost / fallback-обёртки, которая снимет schema-инъекции — см. «Открытые вопросы».

Остальные артефакты:

  • Промпт: packages/analysis-core/src/prompts/biomarker-analysis/diagnostician.md
  • Tools: packages/analysis-core/src/tools/diagnostic-graph.tools.tsDiagnosticGraphExecutor + createDiagnosticGraphTools
  • Порт из Python: fhir-services/src/ai/agents/diagnostician_agent.py

Связано

  • biomarker-graph — что Диагност читает (через DiagnosticGraphExecutor)
  • retriever — следующий шаг, потребляет DiagnosticPlan и идёт в FHIR пациента проверять, какие из предложенных Диагностом связей реально существуют
  • biomarker-analysis-pipeline — pipeline, в котором Диагност — шаг 1
  • biomarker-actuality-service — концептуальный downstream-потребитель через Retriever
  • biomarker-graph-key-loinc — направление, по которому Tier 1/2 уезжают на детерминистический lookup
  • llm-stage-cost-measurement — общий cost-framework для всех LLM-стадий (placeholder)
  • health-report-vocabulary — терминология стадий
  • gemini-doom-loop — mid-decode timeout + nonce-perturbation
  • mastra — фреймворк агентского цикла; diagnostician.mastra.ts использует, diagnostician.raw.ts обходит