Посвящается всем, кто менял промпт в 3 часа ночи и шептал «ну давай, гад, заработай».

Привет, Хабр. Через несколько дней будет 16 лет, как я здесь зарегистрирован. Это моя первая статья. Кек.

Меня зовут Ярослав, днём я занимаюсь продуктом, а по вечерам пилю десктопный AI-ассистент для встреч. Записывает, транскрибирует, суммаризирует и сам раскладывает решения, задачи и факты по базе знаний. Документация, которая пишет себя сама. Под капотом — Tauri (Rust + React), а за agent loop отвечает Mastra — TypeScript-фреймворк для AI-агентов с встроенными evals, tool calling и structured output.

И вот с этим агентом — проблема. Иногда он выдаёт что-то приличное. Иногда галлюцинирует решения, которых не было. Иногда теряет action items. Иногда пишет саммари длиннее самой встречи, что вообще-то противоречит смыслу его существования.

И я делаю то, что делает каждый нормальный фаундер: открываю промпт, меняю пару слов, запускаю на паре примеров, смотрю глазами — «вроде лучше». Деплою. Через день пользователь пишет, что агент выдумал несуществующий deadline. Снова открываю промпт. Цикл повторяется.

Это называется vibes-based development. Ты оцениваешь качество по ощущениям. Проблема не в том, что ощущения врут (хотя они врут). Проблема в том, что нет способа ответить на три вопроса:

  1. Где именно агент ломается? Не «вообще плохо», а «теряет action items в 40% случаев, когда они сформулированы как вопросы».

  2. Стало ли лучше после изменения? Не «вроде лучше на этих трёх примерах», а «faithfulness вырос с 0.64 до 0.78 на 50 test cases, при этом completeness не просел».

  3. Не сломал ли я то, что работало?

Eval — это ответ на все три. По сути — тесты для недетерминированных систем. Ты же не деплоишь обычный код без тестов (надеюсь). AI-агент заслуживает того же, просто тесты выглядят иначе.

В Mastra есть 16 встроенных scorers, LLM-as-Judge, интеграция в CI — но я не понимал, как этим правильно пользоваться. Не «прочитать доку и подключить», а «как выстроить процесс, чтобы агент системно улучшался». Я разобрался — перелопатил Hamel Husain, Eugene Yan, гайды Anthropic, исследования по summarization evaluation — и написал всё в одном месте. Получилось несколько глав: от «что такое eval» до «агент улучшает себя сам, пока я сплю».


Глава 1. Eval с нуля: что это на самом деле

Самое простое определение

Eval — это функция, которая принимает output твоего агента и возвращает оценку: хорошо или плохо (и, опционально, насколько хорошо или плохо).

Вот и всё. Серьёзно. Можно закрывать статью.

Шучу. Но эта простота важна, потому что люди (и я в том числе) склонны усложнять. Давай зафиксируем: eval — это f(output) → score. Всё остальное — детали реализации.

Три вида evals, от простого к сложному

Вид 1: Code-based assertions. Это обычный код, который проверяет output по правилам. Быстро, бесплатно, детерминированно. Но может проверять только формальные свойства — длину, структуру, наличие ключевых слов.

Представь: ты заказал пиццу. Code-based assertion проверяет, что коробка не пустая, что внутри круглый предмет, что он тёплый. Но не может сказать, вкусная ли пицца.

Вид 2: Human evaluation. Человек (ты) читает output и оценивает. Точно, нюансированно, учитывает контекст. Но медленно, дорого, не масштабируется, и ты устанешь к двадцатому примеру.

Это как позвать итальянскую бабушку оценить каждую пиццу. Идеально по качеству, но бабушка не может оценивать 10 000 пицц в день.

Вид 3: LLM-as-Judge. Другая LLM оценивает output твоей LLM по заданным критериям. Масштабируемо, нюансированно, относительно дёшево. Но может быть biased, и её саму нужно валидировать.

Это как нанять food-критика. Он быстрее бабушки, может оценить много пицц, но у него свои предвзятости (может любить тонкое тесто и занижать оценку толстому). И ты должен убедиться, что его вкус совпадает с вкусом твоих клиентов.

Золотое правило: используй все три вида одновременно, на разных уровнях. Code-based assertions ловят грубые ошибки на каждый запрос. LLM-as-Judge оценивает качество на sample. Human evaluation калибрует и валидирует всё остальное.

Что именно мы оцениваем в meeting summary

Прежде чем строить evals, нужно определить: что такое «хорошее саммари встречи»? Это не философский вопрос — это product decision. Вот пять измерений в порядке убывания важности, которые я определил для моего AI-ассистента для встреч:

  • Faithfulness (верность источнику). Каждое утверждение в саммари подтверждается транскриптом. Если агент написал «команда решила перенести запуск на март», а в транскрипте этого не было — это катастрофический провал. Пользователь примет решение на основе выдуманной информации. Это единственный fatal failure mode — всё остальное можно пережить.

  • Action item accuracy (точность задач). Все задачи из встречи найдены, у каждой правильный ответственный и deadline. Это, вероятно, главная причина покупки. Пропущенный action item = сорванная договорённость = отток.

  • Completeness (полнота). Саммари покрывает все ключевые темы и решения из встречи. Не нужно покрывать всё — нужно покрывать всё важное. Определение «важного» зависит от типа встречи (standup vs. стратегическое планирование).

  • Conciseness (лаконичность). Саммари короче транскрипта (очевидно) и при этом не содержит воды. Оптимальная плотность — около 0.15 entities на token (из исследования Chain-of-Density). Если проще: каждое предложение саммари должно нести новую информацию.

  • Coherence (связность). Саммари логично структурировано и читается как связный текст. Современные LLM-ы редко фейлятся на coherence, так что это наименее дифференцирующее измерение.

Запомни этот порядок. Он определит, какие evals мы строим первыми.


Глава 2. Руками: error analysis, который стоит дороже любого инструмента

Почему ручной анализ — это не временная мера до нормальных evals

Hamel Husain — один из самых авторитетных голосов в мире LLM evals — консультировал 40+ компаний. Его главное наблюдение: команды, которые пропускают ручной error analysis, строят бесполезные automated evals. Потому что ты не можешь автоматизировать то, чего не понимаешь.

Это как автоматизировать тестирование до того, как ты руками прошёл по product flow. Ты не знаешь, какие edge cases важны. Ты не знаешь, какие failure modes встречаются чаще. Ты не знаешь, как вообще выглядит «хорошо».

Hamel обнаружил паттерн: на проекте NurtureBoss всего 3 типа ошибок объясняли 60% всех проблем. Три! Не тридцать. Без ручного анализа команда пыталась строить сложные eval-системы, которые мерили всё подряд, но не ловили эти три критических бага.

Как делать error analysis правильно

Шаг 1: Собери 20–50 пар «транскрипт → саммари». Если у тебя есть production-данные — бери оттуда. Если нет — прогони агента на реальных или синтетических транскриптах и сохрани результаты. 20 — абсолютный минимум. 50 — уже достаточно для выявления паттернов.

Шаг 2: Читай. Просто читай. Открой транскрипт, прочитай его. Открой саммари, прочитай его. Делай свободные заметки: что не так, что бесит, что упущено, что выдумано, что слишком длинно, что непонятно. Не пытайся сразу классифицировать — просто записывай наблюдения.

Вот примерный формат заметок (можно в Notion, можно в текстовом файле, можно на салфетке — инструмент не важен):

Пример #7:
- Транскрипт: standup, 15 минут, 4 участника
- Проблема: агент написал «Маша возьмёт на себя рефакторинг API», 
  но в транскрипте Маша сказала «я МОГУ посмотреть, если будет время»
- Тип: галлюцинация обязательства (условное → безусловное)

Пример #12:
- Транскрипт: planning session, 45 минут, 6 участников
- Проблема: два action item пропущены, оба были в самом конце встречи
- Тип: потеря информации в конце длинного транскрипта

Пример #15:
- Транскрипт: one-on-one, 30 минут
- Проблема: саммари занимает 3 страницы при транскрипте на 5 страниц
- Тип: недостаточная компрессия, пересказ вместо суммаризации

Шаг 3: Построй taxonomy failure modes. После прочтения 20–50 примеров у тебя будет куча заметок. Теперь группируй их в категории. Можешь сделать это сам, а можешь скормить заметки Claude и попросить построить классификацию — LLM-ы отлично справляются с этой задачей.

Типичные failure modes для meeting summarization (твоя taxonomy будет своя, но эти встречаются почти у всех):

FAITHFULNESS FAILURES:
├── Галлюцинация обязательств (условное → безусловное)
├── Галлюцинация решений (обсуждение → принятое решение)
├── Неправильная атрибуция (кто что сказал)
└── Выдумывание деталей (даты, цифры, которых не было)

COMPLETENESS FAILURES:
├── Потеря action items (особенно в конце встречи)
├── Пропуск ключевых решений
├── Игнорирование контекста/предпосылок решений
└── Потеря информации при длинных транскриптах (>30 мин)

CONCISENESS FAILURES:
├── Пересказ вместо суммаризации
├── Повторение одной мысли разными словами
├── Включение small talk и нерелевантных отступлений
└── Дублирование информации в разных секциях

STRUCTURAL FAILURES:
├── Отсутствие секции action items
├── Action items без ответственных/deadline-ов
├── Нарушение заданного формата
└── Смешение языков (если транскрипт на русском)

Шаг 4: Посчитай частоту. Пройди по своим 20–50 примерам и для каждого отметь, какие failure modes присутствуют. Посчитай: какой тип ошибки встречается чаще всего?

Вот здесь происходит магия. Ты обнаружишь, что 2–3 типа ошибок покрывают большинство проблем. Эти 2–3 типа — и есть твои первые evals. Не все 15 типов из taxonomy. Не абстрактные «faithfulness» и «completeness». А конкретные, наблюдаемые проблемы.

Сколько времени это займёт

От полудня до недели. Да, это ощущается как потеря времени, когда надо фичи пилить. Но каждый практик, от Hamel Husain до команды Anthropic, сходится: это самая высоко-ROI активность во всём процессе улучшения AI-агента. Ты не сможешь построить полезные evals, если не сделал этот шаг. Без вариантов.

Eugene Yan формулирует жёстче: «Некоторые думают, что ещё один tool, metric или LLM-as-Judge решит проблемы и спасёт продукт. Это обход корневой проблемы. Корневая проблема — ты не смотрел на свои данные».


Глава 3. Первые evals: code-based assertions

Философия: pass/fail, а не оценки

Прежде чем писать код, зафиксируем принцип: binary pass/fail лучше, чем Likert scale. Не 1–5. Не «скорее хорошо». Просто: прошёл или нет.

Почему? Eugene Yan наблюдает: stakeholders просят granular 1–5 scores для гибкости — «чтобы потом подкрутить thresholds». По его опыту, «ровно ноль из них реально это делают». Binary decisions заставляют тебя честно ответить: это приемлемо или нет? Нет середины, нет «ну 3 из 5, наверное ок». Pass или fail.

Если ты не можешь определить boundary между pass и fail — значит, ты ещё не понял, чего хочешь от агента. Вернись к error analysis.

Что можно проверить кодом

Code-based assertions проверяют формальные свойства output-а. Они не могут оценить «качество» текста, но могут поймать грубые структурные проблемы. Вот конкретные примеры для meeting summarization:

// eval-assertions.ts
// Набор code-based assertions для meeting summary

interface Transcript {
  text: string
  duration_minutes: number
  participants: string[]
}

interface Summary {
  text: string
  action_items: ActionItem[]
  decisions: string[]
}

interface ActionItem {
  description: string
  owner?: string
  deadline?: string
}

interface AssertionResult {
  name: string
  pass: boolean
  reason: string
}

function runAssertions(
  transcript: Transcript, 
  summary: Summary
): AssertionResult[] {
  const results: AssertionResult[] = []
  
  // -------- ASSERTION 1: Compression ratio --------
  // Саммари должно быть значительно короче транскрипта.
  // Если оно длиннее 30% — агент пересказывает, а не суммаризирует.
  // Если короче 5% — скорее всего, агент потерял информацию.
  const ratio = summary.text.length / transcript.text.length
  results.push({
    name: 'compression_ratio',
    pass: ratio > 0.05 && ratio < 0.30,
    reason: `Ratio: ${(ratio * 100).toFixed(1)}%. ` +
            `Ожидается 5-30%. ${ratio > 0.30 ? 'Слишком длинно — пересказ вместо суммари.' : ''}` +
            `${ratio < 0.05 ? 'Слишком коротко — потеря информации.' : ''}`
  })
  
  // -------- ASSERTION 2: Участники упомянуты --------
  // Если в транскрипте 4 участника, хотя бы 2 должны появиться в саммари.
  // Саммари без имён — красный флаг: агент не понимает, кто что говорил.
  const mentionedParticipants = transcript.participants
    .filter(name => summary.text.includes(name))
  const participantRatio = mentionedParticipants.length / transcript.participants.length
  results.push({
    name: 'participants_mentioned',
    pass: participantRatio >= 0.5,
    reason: `Упомянуто ${mentionedParticipants.length} из ${transcript.participants.length} участников.`
  })
  
  // -------- ASSERTION 3: Action items присутствуют --------
  // Heuristic: если в транскрипте есть маркеры поручений,
  // в саммари должна быть хотя бы одна задача.
  const actionMarkers = [
    'нужно', 'необходимо', 'давай ты', 'возьми на себя',
    'до пятницы', 'до конца недели', 'к следующей встрече',
    'запланируй', 'подготовь', 'сделай', 'напиши',
    'action item', 'todo', 'задача'
  ]
  const hasActionMarkersInTranscript = actionMarkers
    .some(marker => transcript.text.toLowerCase().includes(marker))
  
  if (hasActionMarkersInTranscript) {
    results.push({
      name: 'action_items_present',
      pass: summary.action_items.length > 0,
      reason: hasActionMarkersInTranscript && summary.action_items.length === 0
        ? 'В транскрипте есть маркеры поручений, но action items в саммари отсутствуют!'
        : `Найдено ${summary.action_items.length} action items.`
    })
  }
  
  // -------- ASSERTION 4: Action items имеют owners --------
  // Action item без ответственного бесполезен.
  // «Нужно сделать рефакторинг» — это не task, это пожелание.
  if (summary.action_items.length > 0) {
    const withOwners = summary.action_items.filter(ai => ai.owner && ai.owner.length > 0)
    const ownerRatio = withOwners.length / summary.action_items.length
    results.push({
      name: 'action_items_have_owners',
      pass: ownerRatio >= 0.8,
      reason: `${withOwners.length} из ${summary.action_items.length} action items имеют ответственного.`
    })
  }
  
  // -------- ASSERTION 5: Нет hallucination markers --------
  // Определённые фразы — почти гарантированный сигнал галлюцинации.
  // Агент не может знать, что было «на прошлой встрече» — 
  // у него в контексте только текущий транскрипт.
  const hallucMarkers = [
    'как мы обсуждали ранее',
    'в предыдущей встрече',
    'как было решено на прошлой неделе',
    'согласно нашему предыдущему разговору',
    'как вы помните'
  ]
  const foundHalluc = hallucMarkers
    .filter(m => summary.text.toLowerCase().includes(m))
  results.push({
    name: 'no_hallucination_markers',
    pass: foundHalluc.length === 0,
    reason: foundHalluc.length > 0
      ? `Обнаружены маркеры галлюцинации: "${foundHalluc.join('", "')}"`
      : 'Маркеры галлюцинации не обнаружены.'
  })
  
  // -------- ASSERTION 6: Язык саммари соответствует транскрипту --------
  // Если транскрипт на русском, а саммари на английском — что-то не так.
  const cyrillicInTranscript = (transcript.text.match(/[а-яё]/gi) || []).length
  const cyrillicInSummary = (summary.text.match(/[а-яё]/gi) || []).length
  const transcriptIsRussian = cyrillicInTranscript / transcript.text.length > 0.3
  const summaryIsRussian = cyrillicInSummary / summary.text.length > 0.3
  
  if (transcriptIsRussian) {
    results.push({
      name: 'language_match',
      pass: summaryIsRussian,
      reason: transcriptIsRussian && !summaryIsRussian
        ? 'Транскрипт на русском, а саммари нет!'
        : 'Язык саммари совпадает с транскриптом.'
    })
  }
  
  return results
}

Почему этого недостаточно (но это отличный фундамент)

Перечитай assertions выше. Они ловят: саммари слишком длинное, пропущены участники, нет action items, нет ответственных, присутствуют маркеры галлюцинации, неправильный язык. Это всё необходимые, но недостаточные условия качества.

Саммари может пройти все шесть assertions и при этом быть ужасным: все факты перевраны, решения выдуманы, но формат соблюдён, длина в норме, участники упомянуты. Code-based assertions — это первый фильтр, который отсекает очевидный мусор. Для оценки содержательного качества нужны LLM-as-Judge. Об этом — следующая глава.

Но! Не пропускай code-based assertions. Они стоят ноль рублей, работают за миллисекунды, детерминированы (один и тот же input всегда даёт один и тот же результат), и ловят ~30% проблем. Это бесплатные 30%.


Глава 4. LLM-as-Judge: как заставить AI оценивать AI

Почему это работает (и когда не работает)

Интуиция: оценить текст проще, чем его написать. Когда ты читаешь саммари встречи, ты довольно быстро понимаешь, что «вот тут враньё» или «вот тут пропустили решение». Тебе не нужно уметь писать идеальные саммари, чтобы оценить чужое. То же самое с LLM-ами: задача classification (хорошо/плохо по критерию) проще, чем open-ended generation.

Исследование Zheng et al. (NeurIPS 2023) подтвердило: сильные judge-модели (GPT-4 уровня) достигают 80–90% согласия с human evaluators. Для сравнения, два человека-эксперта соглашаются друг с другом примерно в 81% случаев. То есть LLM-judge примерно так же надёжен, как второй человек-эксперт.

Когда это не работает: когда задача требует domain expertise, которого нет в модели (например, оценка медицинской точности), или когда критерии качества субъективны и плохо определены (например, «саммари должно быть полезным» — что это значит?).

Для meeting summarization LLM-as-Judge работает хорошо, потому что: (а) вся нужная информация есть в контексте (транскрипт), (б) критерии чёткие (факт есть в транскрипте или нет), (в) не нужен узкоспециальный domain expertise.

Анатомия хорошего judge prompt

Judge prompt — это промпт, который ты даёшь LLM-модели, чтобы она оценила output твоего агента. Хороший judge prompt отвечает на шесть вопросов. Давай разберём каждый, строя конкретный judge для faithfulness:

Вопрос 1: Что оценивается? Не «оцени саммари». А конкретно: «определи, подтверждено ли каждое утверждение в саммари текстом транскрипта».

Вопрос 2: Какие критерии определяют качество? Не «хорошее саммари». А: «утверждение считается faithful, если в транскрипте есть прямое подтверждение или однозначный логический вывод из сказанного. Утверждение считается unfaithful, если оно вымышлено, искажено, или представляет условное как безусловное».

Вопрос 3: Какая scoring scale? Используй маленькие целочисленные шкалы. 1–4 — оптимально (чётное число убирает «средненько»). 1–5 провоцирует ставить тройку на всё подряд. Boolean (pass/fail) ещё лучше, если задача бинарна.

Вопрос 4: Какой контекст нужен? Для faithfulness — транскрипт целиком и саммари целиком. Для completeness — может быть достаточно списка keyfacts из транскрипта.

Вопрос 5: Какой output format? JSON. Всегда JSON. Не проси модель «написать оценку» — ты потом будешь парсить свободный текст regex-ами и рыдать. Structured output = надёжный parsing.

Вопрос 6: Какие примеры иллюстрируют оценки? Один пример (1-shot) обычно оптимален. Исследования показывают, что больше примеров иногда даже ухудшают качество judge.

Собираем всё вместе:

// judges/faithfulness.ts

const FAITHFULNESS_JUDGE_PROMPT = `
Ты оцениваешь верность (faithfulness) саммари встречи исходному транскрипту.

## Задача
Определи, подтверждено ли каждое фактическое утверждение в саммари текстом транскрипта.

## Определения
- FAITHFUL: утверждение прямо подтверждается транскриптом или является 
  однозначным логическим выводом из сказанного
- UNFAITHFUL: утверждение вымышлено, искажено, или представляет 
  условное как безусловное (например, "может быть" → "будет")

## Процедура (выполни пошагово)
1. Перечисли каждое фактическое утверждение в саммари (пронумеруй их)
2. Для каждого утверждения: найди подтверждение в транскрипте или напиши "НЕ ПОДТВЕРЖДЕНО"
3. Посчитай долю подтверждённых утверждений
4. Выставь финальную оценку

## Шкала оценки
1 - FAIL: Саммари содержит выдуманную информацию или фундаментально искажает обсуждение
2 - FAIL: Несколько неподтверждённых утверждений или значительные искажения
3 - PASS: Большинство утверждений подтверждено, но 2-3 не могут быть верифицированы
4 - PASS: Практически все утверждения подтверждены; максимум одна мелкая неточность

## Пример
Транскрипт: "Маша: Я могу посмотреть задачу по рефакторингу, если будет время на этой неделе."
Саммари: "Маша взяла на себя рефакторинг, дедлайн — конец недели."
Анализ: Утверждение unfaithful — условное ("могу, если будет время") представлено как безусловное обязательство с дедлайном.
Оценка: 2 (FAIL)

## Контекст для оценки
ТРАНСКРИПТ:
{transcript}

САММАРИ:
{summary}

Ответь строго в формате JSON:
{
  "claims": [
    {"id": 1, "claim": "...", "evidence": "..." или "НЕ ПОДТВЕРЖДЕНО", "faithful": true/false}
  ],
  "faithful_ratio": 0.XX,
  "reasoning": "краткое обоснование итоговой оценки",
  "score": 1-4,
  "pass": true/false
}
`

Обрати внимание на ключевые решения:

Chain-of-thought перед оценкой. Judge сначала перечисляет claims, потом ищет evidence, потом считает ratio, и только потом ставит score. Это критически важно — без chain-of-thought judge будет «стрелять от бедра», и accuracy упадёт. Модель должна «думать вслух» перед вердиктом.

Шкала 1–4, не 1–5. Чётное число = нет возможности поставить «середину». Judge вынужден склониться к pass или fail. Бинарный split: 1–2 = fail, 3–4 = pass.

Конкретный пример «условное → безусловное». Это не случайный пример — это один из самых частых failure modes для meeting summarization. Judge, увидев этот пример, будет внимательнее к таким случаям.

Практическая реализация на TypeScript

// judges/run-judge.ts
import Anthropic from '@anthropic-ai/sdk'

// В реальности бери модель из конфига, не хардкодь
const anthropic = new Anthropic()

interface JudgeResult {
  claims: Array<{
    id: number
    claim: string
    evidence: string
    faithful: boolean
  }>
  faithful_ratio: number
  reasoning: string
  score: number
  pass: boolean
}

async function judgeFaithfulness(
  transcript: string, 
  summary: string
): Promise<JudgeResult> {
  const prompt = FAITHFULNESS_JUDGE_PROMPT
    .replace('{transcript}', transcript)
    .replace('{summary}', summary)
  
  const response = await anthropic.messages.create({
    model: 'claude-sonnet-4-20250514', // Sonnet как judge — баланс цены и качества
    max_tokens: 4000,
    temperature: 0.1, // Низкая temperature = воспроизводимость
    messages: [{ role: 'user', content: prompt }]
  })
  
  // Парсим JSON из ответа
  const text = response.content
    .filter(block => block.type === 'text')
    .map(block => block.text)
    .join('')
  
  // Модель иногда оборачивает JSON в ```json ... ``` — убираем
  const cleaned = text.replace(/```json\s*|\s*```/g, '').trim()
  
  return JSON.parse(cleaned) as JudgeResult
}

Критический момент: judge из другого семейства моделей

Если твой агент работает на Claude, не используй Claude как judge. И наоборот. Причина — self-preference bias: модели систематически оценивают свои собственные output-ы выше. GPT-4o показывает ~10% inflated win rate для собственных ответов. Ранние модели Claude — до ~25%.

Практическое правило: агент на Claude → judge на GPT-4o (или наоборот). Или используй модель из другого уровня: агент на Claude Sonnet → judge на Claude Opus (более сильная модель менее склонна к самопредпочтению).


Глава 5. Строим eval suite: от первых тестов до CI/CD

Структура eval-проекта

Давай определим, как выглядит eval suite на уровне файловой системы. Это не rocket science — просто папки с файлами:

kratko/
├── src/
│   └── agents/
│       └── summarizer/
│           ├── agent.ts          # Mastra-агент
│           └── prompts.ts        # Промпты агента
├── evals/
│   ├── fixtures/                 # Тестовые данные
│   │   ├── transcripts/          # Сырые транскрипты
│   │   ├── golden-summaries/     # Эталонные саммари
│   │   └── test-cases.json       # Связка: транскрипт → ожидания
│   ├── assertions/               # Code-based assertions (Level 1)
│   │   └── structural.ts
│   ├── judges/                   # LLM-as-Judge prompts и runners (Level 2)
│   │   ├── faithfulness.ts
│   │   ├── completeness.ts
│   │   └── action-items.ts
│   ├── results/                  # Результаты прогонов
│   │   └── .gitkeep
│   └── run-evals.ts              # Главный runner
├── vitest.config.eval.ts         # Конфиг для eval-тестов (отдельный от unit-тестов)
└── package.json

Test cases: формат и как их наполнять

Каждый test case — это набор: input (транскрипт), ожидания (что должно быть в саммари, чего не должно быть), и опционально — golden summary (эталонное саммари, написанное тобой).

// evals/fixtures/test-cases.json
[
  {
    "id": "standup-001",
    "name": "Короткий standup, 3 участника, 2 action items",
    "transcript_file": "transcripts/standup-001.txt",
    "golden_summary_file": "golden-summaries/standup-001.txt",
    "metadata": {
      "meeting_type": "standup",
      "duration_minutes": 12,
      "participants": ["Маша", "Петя", "Аня"],
      "language": "ru"
    },
    "expectations": {
      "min_action_items": 2,
      "must_mention_participants": ["Маша", "Петя"],
      "must_contain_topics": ["рефакторинг API", "баг в авторизации"],
      "must_not_contain": ["предыдущая встреча", "как мы обсуждали"],
      "max_compression_ratio": 0.25
    }
  },
  {
    "id": "planning-001",
    "name": "Длинный planning, 6 участников, сложные решения",
    "transcript_file": "transcripts/planning-001.txt",
    "golden_summary_file": "golden-summaries/planning-001.txt",
    "metadata": {
      "meeting_type": "planning",
      "duration_minutes": 55,
      "participants": ["Маша", "Петя", "Аня", "Вася", "Дима", "Катя"],
      "language": "ru"
    },
    "expectations": {
      "min_action_items": 4,
      "must_mention_participants": ["Маша", "Вася", "Дима"],
      "must_contain_topics": ["Q2 roadmap", "бюджет на инфраструктуру"],
      "must_not_contain": [],
      "max_compression_ratio": 0.20
    }
  }
]

Откуда брать test cases:

Уровень 0 (день 1): Напиши 5 транскриптов руками. Это самый надёжный способ контролировать, что именно ты тестируешь. Каждый транскрипт — конкретный сценарий: короткий standup, длинный planning, конфликтная дискуссия, встреча без action items, встреча с плохим качеством записи.

Уровень 1 (день 3–4): Используй реальные транскрипты из production (если они есть) или попроси друзей записать пару встреч. Анонимизируй данные.

Уровень 2 (неделя 2): Synthetic expansion — используй Claude для генерации вариаций существующих test cases. Об этом подробно — в Главе 7.

Golden summaries: нужны ли они?

Golden summary — это эталонное саммари, написанное тобой для конкретного транскрипта. Это «правильный ответ».

Нужны ли они? Для начала — нет. Вот почему: golden summary полезен для ROUGE/BERTScore метрик, которые сравнивают output с эталоном. Но эти метрики плохо работают для LLM-generated text (исследование MESA 2024 показало низкую корреляцию с human judgment, а 76% реализаций ROUGE содержат scoring errors).

Для LLM-as-Judge golden summary не нужен — judge оценивает output по транскрипту напрямую. Для code-based assertions — тем более.

Когда golden summaries полезны: для калибровки judge-модели (сравнить оценки judge с тем, что ты сам считаешь хорошим) и для regression testing (при изменении промпта прогнать агента на тех же inputs и сравнить с известным хорошим output).

Рекомендация: напиши 10–15 golden summaries для самых важных test cases. Не больше. Остальное покроют assertions и judges.

Собираем eval runner

// evals/run-evals.ts
import { readFileSync } from 'fs'
import { runAssertions } from './assertions/structural'
import { judgeFaithfulness } from './judges/faithfulness'
import { judgeCompleteness } from './judges/completeness'
import { meetingAgent } from '../src/agents/summarizer/agent'

interface TestCase {
  id: string
  name: string
  transcript_file: string
  expectations: Record<string, any>
  metadata: Record<string, any>
}

interface EvalResult {
  test_case_id: string
  timestamp: string
  git_sha: string
  assertions: Record<string, { pass: boolean; reason: string }>
  judges: {
    faithfulness?: { score: number; pass: boolean; reasoning: string }
    completeness?: { score: number; pass: boolean; reasoning: string }
  }
  overall_pass: boolean
}

async function runEvals(options: { 
  runJudges: boolean  // Judge-evals дорогие, не всегда нужны
}): Promise<EvalResult[]> {
  const testCases: TestCase[] = JSON.parse(
    readFileSync('evals/fixtures/test-cases.json', 'utf-8')
  )
  
  const results: EvalResult[] = []
  
  for (const tc of testCases) {
    console.log(`\n📋 Evaluating: ${tc.name}`)
    
    // 1. Загружаем транскрипт
    const transcript = readFileSync(
      `evals/fixtures/${tc.transcript_file}`, 'utf-8'
    )
    
    // 2. Генерируем саммари агентом
    const agentOutput = await meetingAgent.generate(transcript)
    
    // 3. Code-based assertions (Level 1) — всегда запускаем
    const assertions = runAssertions(
      { text: transcript, ...tc.metadata },
      agentOutput
    )
    
    const result: EvalResult = {
      test_case_id: tc.id,
      timestamp: new Date().toISOString(),
      git_sha: process.env.GIT_SHA || 'local',
      assertions: Object.fromEntries(
        assertions.map(a => [a.name, { pass: a.pass, reason: a.reason }])
      ),
      judges: {},
      overall_pass: assertions.every(a => a.pass) // пока без judges
    }
    
    // 4. LLM-as-Judge (Level 2) — только если запрошено
    if (options.runJudges) {
      console.log('  🤖 Running LLM judges...')
      
      const [faithfulness, completeness] = await Promise.all([
        judgeFaithfulness(transcript, agentOutput.text),
        judgeCompleteness(transcript, agentOutput.text)
      ])
      
      result.judges = { faithfulness, completeness }
      
      // Overall pass: assertions + judges
      result.overall_pass = 
        assertions.every(a => a.pass) && 
        faithfulness.pass && 
        completeness.pass
    }
    
    // 5. Логируем результат
    const emoji = result.overall_pass ? '✅' : '❌'
    console.log(`  ${emoji} ${tc.name}: ${result.overall_pass ? 'PASS' : 'FAIL'}`)
    
    if (!result.overall_pass) {
      // Подробности только для failed cases — не засоряем лог
      const failedAssertions = assertions.filter(a => !a.pass)
      for (const fa of failedAssertions) {
        console.log(`     ⚠️  ${fa.name}: ${fa.reason}`)
      }
      if (result.judges.faithfulness && !result.judges.faithfulness.pass) {
        console.log(`     ⚠️  faithfulness: ${result.judges.faithfulness.reasoning}`)
      }
    }
    
    results.push(result)
  }
  
  return results
}

Интеграция в Vitest

// evals/summarizer.eval.test.ts
// Это отдельный от unit-тестов файл. Запускается командой:
// npx vitest run --config vitest.config.eval.ts

import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { runAssertions } from './assertions/structural'
import { judgeFaithfulness } from './judges/faithfulness'
import { meetingAgent } from '../src/agents/summarizer/agent'

// Загружаем test cases один раз
const testCases = JSON.parse(
  readFileSync('evals/fixtures/test-cases.json', 'utf-8')
)

describe('Meeting Summary — Structural Assertions', () => {
  // Structural assertions запускаются ВСЕГДА — они бесплатные и быстрые
  for (const tc of testCases) {
    it(`[${tc.id}] ${tc.name}`, async () => {
      const transcript = readFileSync(`evals/fixtures/${tc.transcript_file}`, 'utf-8')
      const output = await meetingAgent.generate(transcript)
      const assertions = runAssertions({ text: transcript, ...tc.metadata }, output)
      
      // Каждый failed assertion — отдельная ошибка с понятным сообщением
      for (const a of assertions) {
        expect(a.pass, `${a.name}: ${a.reason}`).toBe(true)
      }
    })
  }
})

describe('Meeting Summary — LLM Judge: Faithfulness', () => {
  // Judge-evals дороже, можно пометить тегом и запускать отдельно:
  // npx vitest run --config vitest.config.eval.ts --grep "LLM Judge"
  for (const tc of testCases) {
    it(`[${tc.id}] ${tc.name}`, async () => {
      const transcript = readFileSync(`evals/fixtures/${tc.transcript_file}`, 'utf-8')
      const output = await meetingAgent.generate(transcript)
      const result = await judgeFaithfulness(transcript, output.text)
      
      expect(result.pass, `Faithfulness FAIL (score ${result.score}): ${result.reasoning}`).toBe(true)
    }, { timeout: 60_000 }) // Judge-eval может быть медленным
  }
})


Глава 6. Калибровка judge-модели: как убедиться, что оценщик не врёт

Зачем это нужно

У тебя есть LLM-judge, который выставляет scores. Вопрос: можно ли ему доверять? Большинство команд не задают этот вопрос, и это — по словам Hamel Husain — одна из главных ошибок в построении eval-систем.

Judge — это classifier. Как любой classifier, у него есть precision (когда он говорит «fail», это правда fail?) и recall (ловит ли он все реальные fail?). Если failure mode встречается в 5% случаев, judge с accuracy 95% может просто всегда говорить «pass» и будет «точен» в 95% — но бесполезен.

Процесс калибровки по шагам

Шаг 1: Разметь 30–50 примеров вручную. Берёшь пары (транскрипт, саммари), читаешь, ставишь свой score по тому же rubric, что и judge. Это твой ground truth — «правильные ответы». Да, это 2–3 часа работы. Да, это обязательно.

Шаг 2: Прогони judge на тех же примерах. Теперь у тебя для каждого примера есть две оценки: твоя (human) и judge (LLM).

Шаг 3: Посчитай метрики.

// evals/calibration/calibrate-judge.ts

interface CalibrationPair {
  test_case_id: string
  human_pass: boolean
  judge_pass: boolean
  human_score: number
  judge_score: number
}

function calibrate(pairs: CalibrationPair[]) {
  // Confusion matrix
  let truePositive = 0   // Judge сказал pass, human тоже pass
  let trueNegative = 0   // Judge сказал fail, human тоже fail
  let falsePositive = 0  // Judge сказал pass, human сказал fail (ОПАСНО!)
  let falseNegative = 0  // Judge сказал fail, human сказал pass (мелочь)
  
  for (const p of pairs) {
    if (p.judge_pass && p.human_pass) truePositive++
    if (!p.judge_pass && !p.human_pass) trueNegative++
    if (p.judge_pass && !p.human_pass) falsePositive++  // Judge пропустил баг
    if (!p.judge_pass && p.human_pass) falseNegative++   // Judge придрался зря
  }
  
  const precision = truePositive / (truePositive + falsePositive)
  const recall = truePositive / (truePositive + falseNegative)
  const agreement = (truePositive + trueNegative) / pairs.length
  
  // Pearson correlation между human и judge scores
  const humanScores = pairs.map(p => p.human_score)
  const judgeScores = pairs.map(p => p.judge_score)
  const correlation = pearsonCorrelation(humanScores, judgeScores)
  
  console.log(`
    📊 Judge Calibration Report
    ===========================
    Agreement rate: ${(agreement * 100).toFixed(1)}%
    Precision:      ${(precision * 100).toFixed(1)}%
    Recall:         ${(recall * 100).toFixed(1)}%
    Correlation:    ${correlation.toFixed(3)}
    
    False positives (judge missed real failures): ${falsePositive}
    False negatives (judge was too strict): ${falseNegative}
    
    ${correlation > 0.7 ? '✅ Judge is reliable enough to use' : '⚠️ Judge needs prompt improvement'}
  `)
  
  return { precision, recall, agreement, correlation }
}

Шаг 4: Итерируй промпт judge, если корреляция < 0.7. Посмотри на false positives (judge пропустил реальные проблемы) — это самые опасные случаи. Добавь их как примеры в judge prompt. Перезапусти calibration. Повтори, пока correlation не превысит 0.7.

Шаг 5: Раздели данные на dev/test. 70% примеров — для улучшения judge (dev set). 30% — для финальной проверки (test set). Никогда не оптимизируй judge по test set — иначе ты overfitнешься и не заметишь.

Три вида bias и как с ними бороться

Position bias. Judge предпочитает ответ в зависимости от порядка подачи. Для pairwise comparison: прогоняй оба порядка (A,B) и (B,A), бери среднее. Для pointwise scoring (наш случай): не проблема, потому что мы оцениваем один output.

Verbosity bias. Judge завышает оценку длинным ответам (~15% инфляция). Решение: в rubric явно награждай conciseness. Добавь в judge prompt: «Длинное саммари не является автоматически лучшим. Оценивай информационную плотность, а не объём».

Self-preference bias. Модель оценивает свои output-ы выше. Решение уже обсудили: judge ≠ generator (разные семейства моделей).


Глава 7. Synthetic data: как нагенерировать 100 test cases из 10

Проблема курицы и яйца

Тебе нужны test cases для evals. Для хороших test cases нужны реальные данные. Реальные данные появятся только в production. Production не запустишь без evals. Замкнутый круг.

Решение: synthetic data generation. LLM-ы удивительно хороши в генерации diverse и реалистичных тестовых данных — если дать им правильный фреймворк.

Фреймворк: Features × Scenarios × Personas

Не проси LLM «сгенерируй 50 тестовых транскриптов встреч». Получишь 50 одинаковых generic-транскриптов. Вместо этого используй матрицу:

Meeting types     × Scenarios              × Transcript quality
─────────────────   ─────────────────────   ─────────────────────
standup             много action items      чистый транскрипт
planning            много решений           шумный (ASR-ошибки)
retrospective       brainstorming           смешение ru/en
one-on-one          конфликтная дискуссия   перебивания
all-hands           только информирование   один спикер доминирует
tech review         с дедлайнами            очень короткая (<5 мин)
                    без action items        очень длинная (>60 мин)

Каждая комбинация — уникальный test case. 6 типов × 7 сценариев × 7 качеств = 294 потенциальных test cases. Тебе не нужны все — выбери 50–100 наиболее representative и edge-case-ных.

Грубая ошибка: synthetic data без grounding

Hamel Husain предупреждает: «Команды промптят LLM 'дай 50 тестовых запросов' и получают generic нерепрезентативные данные». Решение:

  1. Ground in production. Если есть хоть 5 реальных транскриптов — используй их как seed для вариаций: «Вот реальный транскрипт standup. Сгенерируй 5 вариаций: измени участников, добавь конфликт, убери action items, сделай длиннее, добавь ASR-ошибки».

  2. Inject real edge cases. Вспомни баг-репорты от пользователей (если есть) или свои заметки из error analysis — превращай каждый реальный баг в test case.

  3. Vary along meaningful dimensions. Не «сделай другой транскрипт», а «сделай транскрипт, где action item сформулирован как риторический вопрос» — конкретный параметр вариации.


Глава 8. Mastra Evals: что есть из коробки и как это использовать

16 built-in scorers

Mastra (пакет @mastra/evals) даёт 16 готовых scorers, которые возвращают normalized score от 0 до 1. Все они используют LLM-as-Judge под капотом — то есть каждый вызов scorer стоит один API call к LLM.

Для моего meeting summarization ассистента самые полезные:

  • faithfulness — проверяет, что каждый claim в output подтверждён context-ом. Это твой scorer №1. Подключи его первым.

  • hallucination — детектирует fabricated claims. Комплементарен faithfulness, но заточен именно на обнаружение выдумок, а не на верификацию каждого утверждения.

  • completeness — проверяет, покрыты ли все необходимые аспекты. Берёт input (транскрипт) и output (саммари), оценивает полноту.

  • prompt-alignment — проверяет, следует ли output инструкциям из промпта. Например: «саммари должно содержать секции Решения, Задачи, Следующие шаги» — scorer проверит, что секции есть.

  • content-similarity — если у тебя есть golden summary, этот scorer сравнит output с эталоном. Полезен для regression testing.

Подключение к агенту

// src/agents/summarizer/agent.ts
import { Agent } from '@mastra/core/agent'
import { 
  createFaithfulnessScorer,
  createCompletenessScorer,
  createHallucinationScorer,
  createPromptAlignmentScorer
} from '@mastra/evals/scorers/prebuilt'

export const meetingAgent = new Agent({
  name: 'meeting-summarizer',
  instructions: `Ты AI-ассистент для суммаризации встреч...`,
  model: { provider: 'ANTHROPIC', name: 'claude-sonnet-4-20250514' },
  
  // Scorers для production monitoring
  scorers: {
    // Faithfulness — scorer №1, проверяем на каждом втором запросе
    faithfulness: {
      scorer: createFaithfulnessScorer({ 
        model: 'openai/gpt-4.1-nano' // Дешёвая модель для scoring
      }),
      sampling: { type: 'ratio', rate: 0.5 }, // 50% запросов
    },
    
    // Hallucination — проверяем реже, это дополнение к faithfulness
    hallucination: {
      scorer: createHallucinationScorer({ model: 'openai/gpt-4.1-nano' }),
      sampling: { type: 'ratio', rate: 0.2 }, // 20% запросов
    },
    
    // Completeness — важно, но дешевле мониторить реже
    completeness: {
      scorer: createCompletenessScorer({ model: 'openai/gpt-4.1-nano' }),
      sampling: { type: 'ratio', rate: 0.3 }, // 30% запросов
    },
  },
})

Mastra автоматически сохраняет результаты scorers в базу (если настроена) и показывает trends в dashboard. Это даёт тебе production monitoring без дополнительного кода.

Когда built-in scorers недостаточны

Mastra scorers покрывают generic dimensions (faithfulness, completeness). Но для meeting-specific проверок тебе нужны custom scorers:

// evals/judges/action-items-scorer.ts
import { createCustomScorer } from '@mastra/evals'

// Custom scorer: проверяет extraction action items
export const actionItemScorer = createCustomScorer({
  name: 'action-item-accuracy',
  
  // Judge prompt для оценки
  prompt: `Ты оцениваешь точность извлечения задач из транскрипта встречи.

Для каждого action item в саммари определи:
1. Есть ли соответствующее поручение в транскрипте?
2. Правильно ли указан ответственный?
3. Правильно ли указан дедлайн (если был)?
4. Не упущены ли action items из транскрипта?

ТРАНСКРИПТ: {context}
САММАРИ: {output}

Верни JSON:
{
  "found_items": [...],
  "missed_items": [...],
  "incorrect_items": [...],
  "score": 0.0-1.0,
  "reasoning": "..."
}`,
  
  model: 'openai/gpt-4.1-nano',
})


Глава 9. Experiment tracking: как понять, что стало лучше

Зачем нужен tracking

Ты поменял промпт. Запустил evals. Увидел числа. Через неделю поменял ещё раз. Запустил evals. Увидел другие числа. Вопрос: стало ли лучше? Без tracking ты сравниваешь «числа, которые я смутно помню» с «числами на экране сейчас».

Experiment tracking решает это: каждый прогон evals сохраняется с git SHA, timestamp, параметрами (модель, промпт версия), и результатами. Ты можешь сравнить любые два прогона и увидеть, что faithfulness вырос с 0.68 до 0.76, но completeness просел с 0.82 до 0.71. Осознанный trade-off вместо слепых изменений.

Braintrust как complement к Mastra

Braintrust — лучший выбор по трём причинам: нативная интеграция с Mastra, TypeScript-native, бесплатный тариф (1 ГБ данных, 10K scores).

// evals/tracked-eval.ts
import { Eval } from 'braintrust'
import { meetingAgent } from '../src/agents/summarizer/agent'

// Braintrust Eval — обёртка, которая логирует всё автоматически
Eval('meeting-summarizer', {
  data: () => loadTestCases(), // Твои test cases
  
  task: async (input) => {
    // Запускаем агента
    const result = await meetingAgent.generate(input.transcript)
    return result.summary
  },
  
  scores: [
    // Code-based assertion как scorer
    (args) => ({
      name: 'compression_ratio',
      score: args.output.length / args.input.transcript.length < 0.3 ? 1 : 0,
    }),
    
    // LLM-as-Judge через Braintrust AutoEvals
    // (автоматически использует их optimized judge prompts)
    Factuality, // Аналог faithfulness
    Closeness,  // Аналог content-similarity (если есть golden summary)
  ],
})

После прогона: npx braintrust eval evals/tracked-eval.ts — открывается dashboard с результатами, diff-ом между версиями, и drill-down в конкретные failed cases.

Минимальный tracking без Braintrust

Если не хочешь внешних зависимостей — простой JSONL-файл с git SHA:

// Минимальный tracking: append результатов в JSONL
import { execSync } from 'child_process'
import { appendFileSync } from 'fs'

function logEvalRun(results: EvalResult[]) {
  const run = {
    timestamp: new Date().toISOString(),
    git_sha: execSync('git rev-parse HEAD').toString().trim(),
    git_message: execSync('git log -1 --format=%s').toString().trim(),
    total: results.length,
    passed: results.filter(r => r.overall_pass).length,
    pass_rate: results.filter(r => r.overall_pass).length / results.length,
    // Средние scores по dimensions
    avg_faithfulness: average(results.map(r => r.judges.faithfulness?.score).filter(Boolean)),
    avg_completeness: average(results.map(r => r.judges.completeness?.score).filter(Boolean)),
  }
  
  appendFileSync('evals/results/history.jsonl', JSON.stringify(run) + '\n')
  console.log(`\n📈 Run logged. Pass rate: ${(run.pass_rate * 100).toFixed(1)}%`)
}


Глава 10. Harness Engineering: агент работает настолько хорошо, насколько хорош его harness

Shift мышления

До этой главы мы улучшали агента через evals: найди ошибку → поправь промпт → проверь eval. Harness engineering — это следующий уровень: не меняй агента, измени среду, в которой он работает.

Термин ввела команда OpenAI Codex в феврале 2026. Три инженера, пять месяцев, ~1 500 PR, миллион+ строк кода — и ни одной строки, написанной вручную. Всё делали AI-агенты. Работа инженеров: проектировать harness.

LangChain показал то же самое: изменение только harness (не модели!) подняло их агента с 52.8% до 66.5% на бенчмарке. Прыжок с Top 30 на Top 5 — без смены модели.

Три рычага harness

Рычаг 1: Context engineering. Что видит твой агент перед генерацией саммари? Только транскрипт? Или ещё: тип встречи, список участников с ролями, примеры хороших саммари для этого типа встречи, и конкретные инструкции «что считать action item»?

Чем больше релевантного контекста — тем лучше output. Не потому что модель умнее, а потому что задача стала яснее. Вот конкретный пример:

// ДО: плохой harness — агент получает только транскрипт
const prompt = `Суммаризируй эту встречу: ${transcript}`

// ПОСЛЕ: хороший harness — агент получает структурированный контекст
const prompt = `
## Контекст встречи
Тип: ${meetingType} (standup | planning | retrospective | one-on-one)
Участники: ${participants.map(p => `${p.name} (${p.role})`).join(', ')}
Длительность: ${durationMinutes} мин
Дата: ${date}

## Инструкции для суммаризации
${INSTRUCTIONS_BY_MEETING_TYPE[meetingType]}

## Определение action item
Action item — конкретное поручение с:
- Описанием задачи (что нужно сделать)
- Ответственным (кто делает) — ОБЯЗАТЕЛЬНО
- Дедлайном (если упомянут) — опционально

НЕ является action item:
- Общее пожелание ("было бы хорошо...")
- Условное намерение ("если будет время, могу...")
- Вопрос без конкретного поручения ("а что если мы...?")

## Формат вывода
### Ключевые решения
...
### Задачи (action items)
| Задача | Ответственный | Дедлайн |
...
### Следующие шаги
...

## Транскрипт встречи
${transcript}
`

Разница огромная. Первый вариант — это «напиши что-нибудь». Второй — конкретная задача с определениями, форматом и контекстом. Модель та же, prompt engineering тот же — но harness (структура контекста) принципиально другой.

Рычаг 2: Architectural constraints. Добавь детерминированные правила, которые ограничивают output агента:

// Post-processing: структурные гарантии
function enforceStructure(agentOutput: string): string {
  // 1. Если нет секции action items — добавь пустую
  if (!agentOutput.includes('### Задачи') && !agentOutput.includes('### Action')) {
    agentOutput += '\n\n### Задачи (action items)\nAction items не обнаружены.'
  }
  
  // 2. Обрежь, если слишком длинно (hard limit)
  const MAX_CHARS = 3000
  if (agentOutput.length > MAX_CHARS) {
    // Не тупо обрезай — попроси модель сократить
    agentOutput = await compressSummary(agentOutput, MAX_CHARS)
  }
  
  // 3. Проверь, что action items в табличном формате
  // (если нет — перефоматируй)
  
  return agentOutput
}

Рычаг 3: Feedback loops. Каждый раз, когда eval фейлится — это не просто баг, это сигнал, что harness неполон. Агент галлюцинирует условные обязательства? Добавь в контекст определение action item с примерами «что НЕ является action item». Агент теряет информацию в конце длинных транскриптов? Разбей транскрипт на chunks и суммаризируй каждый. Агент путает спикеров? Предобработай транскрипт, добавив теги спикеров.


Глава 11. Self-improving eval loops с Claude Code

Концепция: overnight improvement

Представь: ты запускаешь скрипт перед сном. Он берёт твоего агента, прогоняет на 50 test cases, находит failing cases, анализирует паттерны ошибок, модифицирует промпт, прогоняет снова, сравнивает результаты. Пять итераций. Утром ты просыпаешься и видишь git log: пять коммитов, каждый с описанием изменений и метриками до/после. Faithfulness вырос с 0.64 до 0.81.

Так может Claude Code в headless mode с eval loop.

Архитектура loop-а

#!/bin/bash
# self-improve.sh — запусти перед сном, проснись с улучшенным агентом

MAX_ITERATIONS=5
EVAL_SET="evals/fixtures/test-cases.json"
PROMPT_FILE="src/agents/summarizer/prompts.ts"
HOLDOUT_RATIO=0.4  # 40% test cases — holdout, не используется для оптимизации

echo "🌙 Starting self-improvement loop at $(date)"
echo "Max iterations: $MAX_ITERATIONS"

for i in $(seq 1 $MAX_ITERATIONS); do
  echo ""
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  echo "🔄 Iteration $i of $MAX_ITERATIONS"
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
  
  # 1. Прогоняем evals
  echo "📋 Running evals..."
  npx vitest run --config vitest.config.eval.ts --reporter json > "evals/results/iter-${i}.json" 2>&1
  
  # 2. Анализируем результаты и улучшаем промпт через Claude Code
  claude -p "
    Ты улучшаешь промпт AI-агента для суммаризации встреч.
    
    ## Текущий промпт
    $(cat $PROMPT_FILE)
    
    ## Результаты последнего eval прогона
    $(cat evals/results/iter-${i}.json)
    
    ## Задача
    1. Проанализируй failing test cases. Найди паттерны: какие типы ошибок встречаются чаще?
    2. Определи root cause: что в промпте приводит к этим ошибкам?
    3. Предложи конкретные изменения в промпте.
    4. Внеси изменения в файл $PROMPT_FILE.
    5. НЕ МЕНЯЙ holdout test cases (последние 40% в списке).
    
    Важно: делай МИНИМАЛЬНЫЕ, ТОЧЕЧНЫЕ изменения. Не переписывай промпт целиком.
    Одна итерация — одно-два конкретных улучшения.
  " --allowedTools "Read,Write,Edit,Grep,Glob"
  
  # 3. Коммитим изменения
  git add $PROMPT_FILE
  git commit -m "eval-loop: iteration $i — $(date +%H:%M)"
  
  echo "✅ Iteration $i complete"
done

# Финальный прогон на ПОЛНОМ наборе (включая holdout)
echo ""
echo "🏁 Final evaluation on full test set (including holdout)..."
npx vitest run --config vitest.config.eval.ts --reporter json > "evals/results/final.json" 2>&1

echo "📊 Self-improvement loop complete. Check git log for changes."
echo "Final results: evals/results/final.json"

Критические safeguards

Holdout set. 40% test cases не используются для оптимизации. Они — аналог test set в ML: финальная проверка, что улучшения generalize, а не overfitнулись на train cases.

Iteration cap. Без лимита loop может работать бесконечно, делая всё более marginal (и потенциально вредные) изменения. 3–5 итераций — sweet spot.

Minimal changes. Инструкция «делай минимальные, точечные изменения» критически важна. Без неё Claude перепишет весь промпт с нуля на первой итерации, и ты потеряешь контроль над тем, что изменилось.

Git history. Каждая итерация — отдельный коммит. Если iteration 4 всё сломала — легко откатить на iteration 3.


Глава 12. Eval-Driven Development: процесс на каждый день

Три уровня, три каденции

Level 1 — Code-based assertions. Запускаются на каждый commit в CI. Быстро (секунды), бесплатно, детерминированно. Ловят грубые ошибки: неправильный формат, отсутствие обязательных секций, слишком длинное саммари, маркеры галлюцинации.

Level 2 — LLM-as-Judge. Запускается на каждый PR, изменяющий промпт или логику агента. Дороже (API calls), медленнее (минуты), но оценивает содержательное качество. Faithfulness, completeness, action item accuracy.

Level 3 — Human calibration. Раз в 2–4 недели. Ты лично просматриваешь 30–50 саммари, оцениваешь, сравниваешь с judge scores. Это удерживает всю систему от criteria drift — постепенного смещения того, что judge считает «хорошим», от того, что реально хорошо.

Iteration loop

  1. Пользователь жалуется (или ты заметил проблему в monitoring).

  2. Воспроизведи баг: добавь транскрипт в test cases, убедись, что evals его ловят.

  3. Если evals не ловят — добавь новый assertion или judge prompt, который ловит.

  4. Почини промпт/harness.

  5. Запусти evals: новый test case проходит?

  6. Запусти полный eval suite: ничего не сломалось? (regression check)

  7. Deploy.

Anthropic называет это Swiss Cheese Model: ни один evaluation layer не ловит всё, но комбинация слоёв даёт robust coverage. Дырки в каждом слое не совпадают.

«Красный» сценарий: что делать, когда pass rate 100%

Если все твои evals проходят на 100% — это не победа, это красный флаг. Hamel Husain: «Pass rate 70% может означать более meaningful evaluation, чем 100%».

100% pass rate означает одно из двух. Либо твой агент идеален (маловероятно). Либо твои evals слишком мягкие (вероятно). Действия: добавь harder test cases (edge cases, adversarial inputs), ужесточи thresholds, добавь новые dimensions оценки.


Глава 13. Anti-patterns: грабли, на которые наступают все

«У меня Mastra Evals подключён — значит, evals есть»

Подключить scorers — это 1% работы. 99% — определить, что именно ты оцениваешь, создать representative test cases, и откалибровать scorers по human judgment. Mastra Evals без test cases — как Vitest без тестов: установлен, но бесполезен.

«Я буду оценивать overall quality по шкале 1–5»

Нет. Bundled rubric с Likert scale — самый распространённый anti-pattern. Оценка 3 из 5 за «overall quality» не говорит тебе ничего: что плохо? Faithfulness? Completeness? Formatting? Ты не знаешь, что чинить.

Вместо этого: binary pass/fail по scoped критериям. «Есть ли в саммари выдуманные факты? Да/нет». «Все ли action items найдены? Да/нет». Каждый failed eval прямо указывает на проблему.

«Synthetic data — это же ненастоящие данные, они не считаются»

Synthetic data не заменяет production data, но отлично дополняет. И на ранней стадии, когда production data нет — это единственный вариант. Ключ: ground в реальности (используй реальные транскрипты как seeds) и vary along meaningful dimensions (не «ещё один похожий транскрипт», а «транскрипт, где action item замаскирован под вопрос»).

«Я лучше потрачу время на фичи, чем на evals»

Самое коварное заблуждение. Фичи без evals — это скорость без направления. Ты будешь быстро бежать, но не знать, вперёд или назад. Каждое изменение промпта без evals — потенциальная regression, которую ты обнаружишь только по жалобам пользователей.

«Мне нужно 500 test cases для начала»

Нет. 20 test cases, покрывающих top failure modes — это MVP. Можно добавить ещё 30 synthetic variations на второй неделе. 50 test cases — достаточно для первых месяцев. Данные наращиваются со временем: каждый баг-репорт → новый test case.

Goodhart's Law: оптимизация на метрику убивает метрику

Когда мера становится целью, она перестаёт быть хорошей мерой. Если ты оптимизируешь промпт на максимальный faithfulness score — агент научится писать максимально осторожные, безопасные, пустые саммари. «На встрече обсуждались рабочие вопросы» — 100% faithful, 0% useful.

Решение: оценивай несколько dimensions одновременно. Faithfulness + completeness + conciseness. Улучшение одного dimension за счёт другого — не улучшение.


Эпилог: что на самом деле важно

Если выжать из всего этого одну мысль — вот она:

Смотри на свои данные. Не на метрики, не на дашборды, не на pass rate — на сами данные. Открой транскрипт, открой саммари, прочитай, запиши что не так. Это единственный шаг, который нельзя автоматизировать, нельзя делегировать LLM-as-Judge и нельзя пропустить. Всё остальное — code-based assertions, judge prompts, synthetic data, CI/CD, self-improving loops — это масштабирование и автоматизация этого фундаментального акта.

Я не претендую на то, что описал единственно верный подход. Eval-ландшафт меняется быстро: полгода назад harness engineering как термин не существовал. Через полгода появится что-то ещё. Но базовый цикл — посмотри на данные → определи что не так → напиши eval → почини → проверь что не сломал — останется тем же.

Если у вас есть опыт с evals для AI-агентов — особенно для summarization или других NLG-задач — буду рад обсудить в комментариях. Мне правда интересно, как другие решают те же проблемы.

https://kratko.me — в открытом доступе, если интересно посмотреть, что из всего этого получилось.