Перейти к основному содержимому

ADR 0123 — Golden-set регрессионных случаев для семантического суждения агента QA Automação

  • Статус: принят
  • Дата: 2026-08-30
  • Ссылается на (без редактирования): ADR 0020 (находку, которую этот закрывает, — его собственный критерий приёмки документирует семантический шаг gate QA как недетерминированный с локальной моделью и называет направление исправления, которое строит этот ADR: «направить на модель через API, а не надеяться»).

Контекст​

ADR 0020 закрыл критерий приёмки gates PR, но с оговоркой, записанной в самом заголовке раздела: ЗАКРЫТ, но НЕ ДЕТЕРМИНИРОВАН. С локальным qwen2.5-coder:7b критерий — QA сопоставляет бизнес-правило с тестом, дважды в одном PR — закрылся только на 10-м из 11 прогонов. 11-й, запущенный сразу после без изменений кода, не закрылся: модель завершила цикл, не вызвав emit_qa_verdict. ADR 0020 явно называет это так: семантический шаг — «роль, которая хуже всего ложится на маленькую модель», и объявляет направление исправления, не строя его: «направить DEMO_QA_MODEL на модель через API».

Это направление так и не стало механизмом — оно осталось задокументированным, но не построенным. Внешнее code review работы этой сессии классифицировало 15 находок по приоритету; 14 из 15 закрылись в ходе сессии. 15-я и последняя — эта, предложенная без связанной стоимости построения: «golden-set (5-10 случаев, свободный ожидаемый вывод, фиксированная модель, допустимые падения для начала) превратил бы „демо зависит от суждения 7B“ в сигнал тенденции».

Три факта, подтверждённые чтением кода, а не предположенные, определили, что можно было построить сегодня:

  1. Суждение можно вызвать изолированно, без всей машины состояний. Engine.Gates.QaAutomacaoAgent.run/5 уже вызывается так в qa_automacao_agent_test.exs — без QaLeadServer, без GateState, без PR, без git-репозитория, без backlog. Единственное, что отделяет этот существующий тест от настоящего golden-set, — замена фальшивого клиента LLM на настоящий.
  2. Настоящий клиент требует реально запущенной и доступной api. Engine.Sessions.EngineApiClient.Live.llm_turn/5 читает Application.fetch_env!(:engine, :api_url), заполняемый только config/runtime.exs (через API_URL) — никогда config/test.exs. Это не модульный тест в обычном смысле: на практике это «прогон против dev-стека», та же позиция, что уже у demo-pr-gates.ts.
  3. CI сегодня невозможен — подтверждено, а не предположено. Ноль упоминаний ollama в .github/workflows/; сервис compose стоит за profiles: ["local-llm"] и никогда не запускается docker/smoke.sh. Ноль секретов LLM в репозитории. И сама культура CI отвергает молчаливо допустимые падения: pr-police.yml — единственное место в репозитории, использующее continue-on-error, и даже там ошибка повторно выбрасывается на финальном шаге. Сегодня нет проверки, которая могла бы стать красной без последствий. Подключение этого к CI требует секрета LLM через API или новой инфраструктуры (runner с GPU, шаг pull из Ollama) — решение человека, а не то, что строится по выбору.

Решение​

Где живёт. apps/engine/test/engine/gates/qa_automacao_agent_golden_test.exs, use Engine.DataCase, с переиспользованием той же конструкции dev_state/dev_context из qa_automacao_agent_test.exs. Это ExUnit, а не новая Mix task: сегодня нет собственной Mix.Task нигде в apps/engine/lib, а изолированный тест уже является естественной формой этого кода для этого класса вещей — в отличие от стороны api, где demo-pr-gates.ts — скрипт, потому что там нет эквивалента «вызвать функцию напрямую».

Постоянный тег исключения, никогда не обнаружение доступности. Шаблон test_helper.exs для gitleaks/hadolint/yamllint/actionlint автоматически включает помеченный тест, когда бинарник найден на машине, — верно для бесплатного детерминированного инструмента и неверно здесь. На этой машине разработчика Ollama уже работает постоянно; автоматическое включение по доступности заставило бы golden-set срабатывать внутри любого mix test, тратя токены без предупреждения и внося реальную нестабильность в набор тестов, который сегодня на 100% детерминирован.

ExUnit.start(exclude: [:golden_set_qa | binary_exclusions])

Его запускает только mix test --only golden_set_qa, плюс alias mix golden_set.qa для находимости — та же дисциплина, которую demo-pr-gates.ts уже объявляет в собственном заголовочном комментарии: «NOT deterministic ... run deliberately, not in CI.» setup_all пропускает (через возврат {:skip, reason} ExUnit в теле теста, а не через выброшенный сбой), когда api недоступна по API_URL, — это предварительное условие окружения, а не дефект кода.

Заполнение внешним TS-скриптом, вызываемым через System.cmd. Engine не может создать реальные строки (проект, сессия, binding модели), не дублируя бизнес-правила, живущие в самих use cases api, — и в любом случае нет сырого пути Ecto, достигающего нужной базы. Это стоимость обвязки, которую этот ADR объявляет открыто, а не прячет: apps/api/scripts/seed-golden-set-qa.ts — это межъязыковой System.cmd, новый для этого репозитория (ноль прецедентов теста на Elixir, вызывающего pnpm/ts-node через shell).

Вторая, совсем не очевидная причина, по которой это должно быть так: golden-тест использует Engine.DataCase, чей sandbox указывает на изолированную базу engine_test (то же соглашение, которое уже использует qa_automacao_agent_test.exs). Скрипт заполнения работает внутри собственного процесса api, записывая в реальную dev-базу (DATABASE_URL), которую работающие серверы api и engine действительно используют. Это две разные базы — ничто, что Engine.Repo читает в golden-тесте, не увидит строк, записанных скриптом, и наоборот. Как следствие, Engine.Actions.Workspace/Engine.Dev.WorktreeManager (которые читают project_repositories через Engine.Repo) не могут материализовать worktree случаев этого теста: скрипт заполнения сам делает git checkout (простой git clone bare-репозитория, который он только что подготовил) и возвращает путь готового worktree в JSON-выводе. Golden-тест только потребляет этот JSON — никогда не обращается к Postgres, чтобы что-то найти.

Скрипт заполнения:

  • Создаёт пользователя + workspace и по проекту на каждый случай (подготовленный локальный git-репозиторий соотносится с проектом 1:1 — ProjectRepository.remoto_de_trabalho/1 ищет единственную строку по project_id).
  • Разрешает или создаёт строку models для GOLDEN_SET_QA_MODEL (по умолчанию qwen2.5-coder:latest) — в отличие от более старых демо, которые бросают ошибку, если модели ещё нет в фиксированном каталоге заполнения (MODEL_SEEDS в seed.ts). Golden-set по замыслу нацелен на произвольные локальные модели Ollama, и привязка к фиксированному каталогу означала бы редактирование seed.ts каждый раз, когда кто-то захочет измерить новую.
  • Активирует модель для workspace (SetModelsActiveUseCase) — шаг, который более старые демо-скрипты пропускают и отсутствие которого сегодня заставляет SetModelBindingUseCase бросать ModelNotBindableError('inativo') (отсутствие в workspace_models означает «выключено», RN-043/ADR 0049). Это похоже на дрейф в старых демо, а не на то, что этот ADR должен молча исправить там, — они вне объёма.
  • Делает binding модели в chaveDeAgente(project.id, 'qa-automacao') — 'qa-automacao', никогда не 'qa': начиная с Фазы 8b реально работающий субагент — это qa-automacao/qa-performance-seguranca; binding в 'qa' не имеет эффекта (каскад падает на binding проекта). Скопировано из demo-pr-gates-area-qa.ts:281, а не из более старого demo-pr-gates.ts, который всё ещё использует старый ключ.
  • Вызывает SeedAgentAreasUseCase.execute(project.id) — без этого agent_areas остаётся пустой для проекта, созданного сырым insert (а не через CreateProjectUseCase), и первый ход LLM, который делает агент QA, падает с 404 от RecordLlmUsageUseCase (AgentAreaRepository.incrementSpent требует существования строки — metering обязателен, RN-036, никогда не «по возможности»). Найдено эмпирически: первый реальный прогон этого harness вернул blocked(infra) во всех шести случаях до добавления этого вызова.
  • Подготавливает репозиторий, коммитит каркас случая, а затем материализует worktree внутри projectScopeRoot(project) (git clone, до записи permissions.json — клонирование требует пустого целевого каталога). Этот порядок и это место несущие, а не случайные: потолок области пути ADR 0055 в decide() отказывается автоматически одобрять любое действие terminal, чей cwd выходит за корень области самого проекта, как бы хорошо команда ни совпадала с шаблоном allow. Первая версия этого скрипта клонировала в отдельный черновой каталог под os.tmpdir() — каждый случай навсегда возвращал awaiting_approval, потому что npm test никогда не проходил проверку области. Найдено эмпирически, так же как пробел agent_areas выше.
  • Добавляет DEV_TERMINAL_ALLOW_PATTERNS в permissions.json проекта (тот же механизм, что уже использует ActivateExecutionUseCase) — без этого значение по умолчанию decide() — require_approval для каждого вызова terminal, и emit_qa_verdict никогда не может увидеть в истории terminal с выходом 0.
  • Создаёт и активирует по сессии на случай (CreateSessionUseCase + TransitionSessionUseCase).
  • Печатает JSON-объект в stdout — {"model": "...", "cases": [{"id", "projectId", "sessionId", "worktreePath", "story", "task", "expectedVerdict"}, ...]}.

Без очистки — та же позиция, что у demo-pr-gates.ts (суффикс с отметкой времени, никогда не удаляется).

Шесть случаев, каждый зондирует отдельную таблицу суждения, в которой LLM-оценщик действительно может ошибиться, — а не шесть вариаций одного теста:

  1. rf-covered — RF_1 из demo-pr-gates.ts, дословно (покрыта) → approved. Положительный контроль, уже доказанный от начала до конца.
  2. rf-uncovered — RF_2 из demo-pr-gates.ts, дословно (явно без покрытия) → changes_requested. Отрицательный контроль, уже доказанный.
  3. rf-single-clean — RF_1 из demo-pr-gates-area-qa.ts (единственное правило, покрыто, чистое одобрение) → approved.
  4. rf-mismatched-filename — правило о enviar(), покрытое тестом, который живёт в test/outro-modulo.test.js (не enviar.test.js) → approved. Зондирует, читает ли QA содержимое теста, а не только соглашение об именах файлов.
  5. rf-partial-coverage — правило, называющее счастливый путь и явный случай отказа (cancelar(pedido) отмечает статус и бросает ошибку, когда уже доставлено); тест есть только у счастливого пути → changes_requested. Зондирует, штампует ли QA частичное покрытие как полное.
  6. rf-skipped-test — единственный тест, «покрывающий» правило, — это test.skip(...) → changes_requested. Зондирует, засчитывает ли QA отключённый тест как покрытие.

Два почти-попадания, которые ADR 0020 уже документирует (галлюцинация вызова инструмента, QA, пытающийся исправить проверяемый код), намеренно оставлены за бортом — это уже детерминированная и дешёвая регрессия в apps/engine/test/engine/harness/tool_call_recovery_test.exs:79, без участия реальной модели. Добавление их сюда дублировало бы это покрытие и размыло бы то, что этот harness реально измеряет: суждение правило-против-теста, а не общее плохое поведение QA.

Порог, а не бинарный gate. Та же философия храповика, что у verificarPiso в scripts/ci/coverage-floor.ts (>=, никогда >; файл редактирует только человек; скрипт никогда его не переписывает) — что ровно совпадает с тем, о чём уже просили «свободный ожидаемый вывод, допустимые падения для начала». apps/engine/test/fixtures/golden_set_qa/floor.json имеет ключ по модели (смена модели законно меняет достижимый порог) и хранит количество плюс итог ({"passRate": N, "of": 6}, а не процент) — чтобы у golden-set, который позже вырастет за пределы шести случаев, старая запись не читалась молча неверно. Нет записи для используемой модели → тест падает, сообщая наблюдаемое число попаданий и прося человека добавить запись, — никогда не записывается автоматически, то же непреложное правило, которое уже установил coverage-floor.ts.

Один тест, а не шесть. Шесть случаев выполняются внутри единственного блока test (помеченного @moduletag :golden_set_qa), а не в шести отдельных. mix test не гарантирует порядок между тестами модуля; разбиение на шесть рисковало бы тем, что утверждение о пороге прочитает неполное подмножество, если предполагаемый «седьмой тест- агрегатор» запустится раньше, чем остальные закончат заполнять контекст. Прогон всех шести с агрегацией в конце — это также то, что позволяет человеку прочитать шесть исходов вместе для отладки, напечатанными в stdout.

ownership_timeout пришлось сделать настраиваемым в Engine.DataCase. Найдено эмпирически при прогоне против gpt-oss:20b: реальный ход LLM против большой модели, загружающейся в первый раз, может минутами не обращаться к Postgres. ownership_timeout по умолчанию 60 с у Ecto.Adapters.SQL.Sandbox посреди вызова забрал соединение, которое golden-тест одолжил; следующий запрос через Engine.Repo — файлы инструкций внутри ToolLoop.Default.init/1, через который проходит каждый агент gate, персонализирует он инструкции агента или нет, — умер с «owner process exited», выдав характеристику скорости модели за сбой инфраструктуры. Engine.DataCase.setup_sandbox/1 теперь читает необязательный tags[:ownership_timeout] (nil сохраняет существующее значение по умолчанию 60 с для любого другого теста набора); golden-тест помечает себя ownership_timeout: :infinity.

Последствия​

Подключение к CI — объявленный TODO(humano), не построенный. Оно требует секрета LLM через API или новой инфраструктуры (runner с GPU, шаг pull модели из Ollama) — это не выбор, который может сделать эта сессия; нужно решение человека и, в случае инфраструктуры, бюджет. Зафиксировано в разделе «Pendências com dono humano» в CLAUDE.md, там же, где этот класс блокировок уже живёт.

Измеренные реальные доли попаданий (2026-08-30, нативные процессы на хосте — api через pnpm --filter api start:dev, engine через mix phx.server, оба направлены на локальный Postgres и на собственный Ollama машины на localhost:11434, без Docker Compose в этом конкретном прогоне):

модельпрогоны (попаданий/6)
qwen2.5-coder:latest1, 3, 2, 4, 5
gpt-oss:20b5, 4

qwen2.5-coder:latest заметно нестабилен — преобладающий режим отказа между прогонами состоял в том, что модель вызывала инструмент terminal с пустым аргументом command, который decide() правильно отказывается автоматически одобрять (ни один шаблон не совпадает с пустой строкой), оставляя случай навсегда в awaiting_approval. Это ровно тот класс нестабильности вызова инструментов, который ADR 0020 уже нашёл в этом семействе моделей, — воспроизведённый здесь как измеренная тенденция, а не единичный анекдот.

gpt-oss:20b заметно надёжнее (4-5/6 в двух прогонах, ~в 4 раза медленнее на случай). Его единственная повторяющаяся ошибка — rf-partial-coverage — штамп approved на правиле со случаем отказа без теста, — а это ровно тот пробел суждения, который этот случай призван выявить, а не дефект фикстуры.

С учётом этого gpt-oss:20b — рекомендуемое значение по умолчанию отныне для любого потока, зависящего от надёжности семантического шага gate QA — тот же вывод, который ADR 0020 предсказал, не имея возможности измерить. GOLDEN_SET_QA_MODEL/значение по умолчанию скрипта заполнения пока остаётся qwen2.5-coder:latest (смена значения по умолчанию демо/harness — отдельное решение от фиксации измеренного); две записи файла порога делают реальные потолки двух моделей видимыми рядом для того, кто будет принимать это решение.

Зарегистрированный порог (floor.json) намеренно ниже лучшего наблюдаемого прогона каждой модели — храповик, а не стремление: qwen2.5-coder:latest на 1/6, gpt-oss:20b на 4/6.

Механизм доказан, но обвязка скрипта заполнения теперь — новая несущая поверхность. Два пробела на стороне api, найденные эмпирическими прогонами этого ADR, — agent_areas, не заполненная для проекта, созданного сырым insert, и worktree, который должен жить внутри projectScopeRoot, чтобы потолок области пути в decide() вообще одобрил хоть что-то автоматически, — не являются дефектами gates или decide(); это ровно тот тип пробела «демо забыло шаг, о котором ActivateExecutionUseCase обычно заботится», который сам ADR 0020 находил снова и снова. Зафиксированы здесь, а не спрятаны, так же как ADR 0020 зафиксировал девять неудачных прогонов до того, как десятый закрылся.