Runbook: пилот product family — пять семей, канарейка на ИБП

Порядок вывода области совместимости в прод: схема и образ, членство, черновики профилей по пяти пилотным семьям, просмотр человеком, публикация, пересборка read-model, теневой замер с гейтами и — только после зелёного замера — включение рычага на ОДНОЙ семье (ИБП) с немедленным откатом.

Клиентскую выдачу меняет ровно один шаг — девятый. Шаги 1–8 готовят и измеряют, и пройти к девятому без зелёного отчёта восьмого нельзя.

Severity (default): P3 — плановая операция, не инцидент Owner: backend / catalog Решения: ADR-0075, ADR-0076 План: docs/superpowers/plans/2026-08-07-product-family-pilot.md Отчёт отбора: docs/superpowers/handoffs/2026-08-07-family-membership-dry-run.md

Что этот runbook меняет и чего не меняет

ШагЧто меняетОбратимость
1Схему: миграции 0295 (семьи и профили) и 0296 (сид пилотных семей)Таблицы аддитивны, откат схемы не нужен
2Ничего: только чтение
3Строки product_family_members пилотных семейDELETE строк auto, ручные решения не трогаются
4Версии профилей в статусе draftОтклонение через ручку
5Ничего: только чтение
6Один профиль семьи в статус active, прежний в supersededПубликация следующей версии; прошлые версии остаются
7Строки canonical_family_axis_lookupПересборка из фактов повторяема
8Ничего: только чтение
9family_path_enabled ОДНОЙ семьиТот же вызов с enabled=false, секунды

Глобальный ANALOGS_FAMILY_PATH_ENABLED остаётся false до шага 9. На шаге 9 его поднимают через deploy-managed конфигурацию до рычага семьи: пока у всех семей family_path_enabled = false, это не меняет клиентскую выдачу. Он нужен как аварийный выключатель всего пути; область клиентского изменения всё равно задаёт аудируемый рычаг одной семьи.

До шага 9 подбор аналогов идёт прежним категорийным путём. Это не предположение: пока у семьи опущен рычаг, ResolveFamily возвращает RolloutOn = false, и pathFor выбирает старый путь.

Предусловия

  1. В main присутствуют коммиты пилота вплоть до 1d214941 (пересборка read-model только из active-профиля). Более ранний образ на шаге 7 может собрать lookup из черновика и применить непринятые правила к живому пути. Выложенный образ собран из того же SHA, что и миграции, — иначе бинарник и схема разойдутся.
  2. ANALOGS_FAMILY_PATH_ENABLED равен false. Проводка живёт в scripts/render_production_env.sh, а не в deploy/prod-env-template.env: рычаг читает api-server на App-VPS, и значение из шаблона DB-VPS до него не доходит. Проверять на App-VPS: docker exec <api-server> env | grep FAMILY.
  3. Свободного места на /var DB-VPS — не меньше 20 ГБ (df -h /var). Миграции лёгкие, но запас нужен под WAL.
  4. Окно вне бэкапа: 0295/0296 не содержат CONCURRENTLY, но общее правило выкладки миграций сохраняется (см. migrate-on-boot).
  5. Под рукой открыт отчёт отбора: числа «до» нужны для сверки на шаге 3.

Как запускать команды пилота

Четыре команды пилота лежат в обычном runtime-образе воркеров и запускаются на DB-VPS разовым контейнером — тем же приёмом, каким выкладка гоняет миграции. Запуск с ноутбука через туннель не используется: бинарник обязан совпадать с выложенным SHA, а строка подключения не должна попадать ни в историю оболочки, ни в этот документ.

ssh tracium-db
cd /srv/tracium/infra/deploy/docker
COMPOSE="docker compose --env-file /srv/tracium/infra/.env \
  --env-file /srv/tracium/infra/deploy/.env"
sudo mkdir -p /srv/tracium/family-pilot   # переживает удаление контейнера

Что важно знать про этот способ:

  • -dsn не передаётся: команда берёт POSTGRES_DSN из окружения сервиса.
  • family-profile-draft запускается под charnorm-worker: только у него в окружении есть LLM_BASE_URL и LLM_API_KEY, без них команда откажется стартовать. family-membership, family-axis-rebuild и family-shadow-sweep ходят только в базу и запускаются под supplier-sync.
  • У всех команд с -apply режим показа открывает соединение с default_transaction_read_only: без флага запись невозможна по устройству соединения, а не по договорённости. У family-shadow-sweep флага записи нет вовсе — соединение всегда только читает.
  • --no-deps обязателен: поднимать зависимости ради разовой команды не нужно.
  • --entrypoint /usr/local/bin/<команда> обязателен: иначе Compose сохраняет штатную команду сервиса (supplier-sync или charnorm-worker), а не запускает переданный бинарник. Пустой отчёт при коде 0 не является успешным прогоном.
  • -T обязателен при перенаправлении вывода, иначе compose выделит терминал и в файл попадёт мусор.
  • Отчёт идёт в stdout, журнал — в stderr, поэтому перенаправляется только stdout. Файлы внутри контейнера исчезают вместе с ним; всё, что должно пережить прогон, кладётся в примонтированный /work.

Шаг 1. Выложить схему и код

Обычная выкладка через CI, руками на хост ничего не копируется.

# после мержа в main — дождаться зелёного пайплайна и выкладки
ssh tracium-db "docker exec tracium-postgres-1 psql -U tracium -d tracium -c \
  \"SELECT version_id, is_applied FROM goose_db_version ORDER BY version_id DESC LIMIT 3\""

Ожидание: применены 295 и 296.

Команды пилота приехали в образе:

cd /srv/tracium/infra/deploy/docker
docker compose --env-file /srv/tracium/infra/.env \
  --env-file /srv/tracium/infra/deploy/.env \
  run --rm --no-deps -T supplier-sync sh -c \
  'for c in family-membership family-profile-draft family-shadow-sweep \
     family-axis-rebuild; do command -v "$c" || { echo "НЕТ: $c"; exit 1; }; done'

Ожидание: четыре пути в /usr/local/bin и код возврата 0. Строка НЕТ: — образ собран до того, как команда попала в список сборки; шаги, которым она нужна, неисполнимы, и обходить их ручным SQL нельзя.

Индексы миграции действительны:

SELECT c.relname, i.indisvalid
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname LIKE '%family%' AND NOT i.indisvalid;

Ожидание: ноль строк. Непустой ответ — индекс остался недостроенным; отбор семьи пойдёт последовательным чтением по горячей таблице. Стоп до перестройки.

Предел пула семьи выставлен. Цена подбора сидит не в отборе (5–9 мс), а в ранжировании и чтении фактов. Перезамер 2026-08-08 на прогретой базе показал, что задержку предел почти не покупает (700 — 60 мс, 1200 — 66 мс, 2000 — 76 мс при бюджете 400 мс), а состав — покупает: предел 700 теряет 6 доказанных аналогов из 41, предел 1200 — 2. Выбрано 1200. Значение живёт в scripts/render_production_env.sh (ANALOGS_FAMILY_POOL_LIMIT=1200) и читается api-server на App-VPS. Полный разбор — docs/superpowers/handoffs/2026-08-08-family-pool-limit-measurement.md.

ssh tracium-app "docker exec <api-server> env | grep ANALOGS_FAMILY_POOL_LIMIT"

Ожидание: 1200. Пусто — действует потолок кода 2000: подбор станет дороже на хвосте, а состав выдачи изменится против измеренного.

Проверка состояния семей — обязательная:

SELECT code, status, family_path_enabled FROM product_families ORDER BY code;

Ожидание: пять строк, у всех status = 'draft' и family_path_enabled = false. Любое иное значение — стоп: раскатка не должна начинаться сама.

SELECT f.code, count(*) AS members,
       count(*) FILTER (WHERE m.state = 'auto') AS auto_rows,
       count(*) FILTER (WHERE m.state = 'manual_override') AS manual_rows
FROM product_family_members m
JOIN product_families f ON f.id = m.family_id
GROUP BY f.code ORDER BY f.code;

Ожидание на 2026-08-08: одна строка ups, members = auto_rows = 3286, manual_rows = 0; остальных семей нет. Это ранее записанный пилотный набор, а не действие миграции. Любая другая семья или ручное решение — стоп до выяснения.

Шаг 2. Отчёт отбора по семье ИБП (только чтение)

$COMPOSE run --rm --no-deps -T \
  --entrypoint /usr/local/bin/family-membership \
  supplier-sync \
  -family ups -batch 500 > /srv/tracium/family-pilot/ups-membership-dryrun.json

Соединение открывается с default_transaction_read_only, поэтому запись невозможна не по договорённости, а по устройству.

Сверить с отчётом от 2026-08-07:

ВеличинаОжидание
families[0].matched3475 на базисе 2026-08-08
conflict_count0
modedry-run
checkpoint_resetотсутствует (файла продолжения ещё нет)
Примеры каноновдесять идентификаторов, открыть два-три в админке и убедиться глазами, что это ИБП, а не аккумуляторы к ним

Старый отчёт от 2026-08-07 (3183) больше не является порогом: на 2026-08-08 текущая область содержит 3475 канонов, из них 189 добавились только в трёх явно разрешённых категориях ИБП. Новое расхождение от 3475 более 2 %, ненулевые конфликты либо пример принадлежности (аккумулятор, батарейный шкаф, аксессуар) — стоп и пересмотр migrations/data/0296_pilot_families.json.

Шаг 3. Запись членства одной семьи

Только после принятого шага 2.

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-membership \
  supplier-sync \
  -family ups -apply -batch 500 \
  -checkpoint /work/ups-membership.cursor \
  > /srv/tracium/family-pilot/ups-membership-apply.json

Курсор лежит в /work, а не в /tmp контейнера: с --rm файл из контейнера исчезнет, и повторный запуск пойдёт с начала выборки.

Курсор обязателен: обход идёт пачками, и без файла продолжения повторный запуск начнёт сначала. Отпечаток выборки в файле обесценит курсор, если между запусками изменится область семьи.

Сверка сразу после прогона:

SELECT count(*) AS members,
       count(*) FILTER (WHERE state = 'auto')            AS auto_rows,
       count(*) FILTER (WHERE state = 'manual_override') AS manual_rows
FROM product_family_members m
JOIN product_families f ON f.id = m.family_id
WHERE f.code = 'ups';

Ожидание: members совпадает с families[0].matched из шага 2 (допуск — только естественный прирост каталога между прогонами), manual_rows = 0.

SELECT count(*) FROM product_family_members m
JOIN product_families f ON f.id = m.family_id WHERE f.code <> 'ups';

Ожидание: 0 — записана ровно одна семья.

Клиентская выдача не изменилась — сравниваются два снимка ответа, снятые до и после записи. Проверки завершаются ненулевым кодом: шаг, который печатает «СТОП» и продолжается, проверкой не является.

Сначала кладём рядом снимающий скрипт. Он же валидирует снимок, поэтому битый или изменившийся ответ останавливает операцию в момент снятия, а не при сверке:

cat > /tmp/ups-analogs-snapshot.sh <<'SH'
#!/usr/bin/env bash
set -euo pipefail
 
out=${1:?укажите путь файла снимка}
: "${API_KEY:?не задан API_KEY}"
: "${UPS_CANONICAL:?не задан UPS_CANONICAL — возьмите canonical_id из примеров шага 2}"
 
# --fail-with-body: HTTP-ошибка обязана валить скрипт, а не тихо ложиться в файл.
# На curl старше 7.76 замените на --fail (тело ошибки при этом не сохранится).
curl -sS --fail-with-body \
  "https://api.tracium.ru/v1/canonical/${UPS_CANONICAL}/analogs?limit=20" \
  -H "Authorization: Bearer ${API_KEY}" > "$out"
 
# Контракт ответа: results обязан существовать и быть массивом. Иначе сломан сам
# эндпоинт, а не только путь подбора, и сверка составов ничего не значит.
jq -e '(.results | type) == "array"' "$out" >/dev/null || {
  echo "СТОП: в снимке $out нет массива results" >&2
  exit 1
}
 
# Поля mode сегодня нет ни в конверте, ни у кандидата. Его появление означает,
# что подбор ушёл на family-path.
jq -e 'has("mode") | not' "$out" >/dev/null || {
  echo "СТОП: в снимке $out поле mode в конверте ответа" >&2
  exit 1
}
jq -e '[.results[] | has("mode")] | any | not' "$out" >/dev/null || {
  echo "СТОП: в снимке $out поле mode у кандидата" >&2
  exit 1
}
 
echo "снимок $out: контракт results на месте, mode отсутствует"
SH
chmod +x /tmp/ups-analogs-snapshot.sh

Снимок до записи членства:

export API_KEY=<ключ публичного API>
export UPS_CANONICAL=<canonical_id из примеров шага 2>
/tmp/ups-analogs-snapshot.sh /tmp/ups-analogs-before.json

Снимок после записи членства — тем же скриптом, значит с теми же проверками:

/tmp/ups-analogs-snapshot.sh /tmp/ups-analogs-after.json

Сверка составов. Сравниваются отсортированные идентификаторы кандидатов, а не тело целиком: очки ранжирования зависят от редкости значений и дрейфуют от фоновых пересчётов, состав — нет.

set -euo pipefail
jq -r '.results[].canonical_id' /tmp/ups-analogs-before.json | sort > /tmp/ups-ids-before.txt
jq -r '.results[].canonical_id' /tmp/ups-analogs-after.json  | sort > /tmp/ups-ids-after.txt
 
if ! diff -u /tmp/ups-ids-before.txt /tmp/ups-ids-after.txt; then
  echo "СТОП: состав кандидатов изменился после записи членства" >&2
  exit 1
fi
echo "состав выдачи не изменился"

Ожидание: оба снимка печатают строку об успехе, diff -u не выводит различий, финальная строка — «состав выдачи не изменился». Любой ненулевой код возврата означает остановку пилота.

Стоп-условие: ошибка HTTP, пропавший results, появившееся mode на любом уровне, непустой вывод diff -u. Каждое означает, что запись членства задела клиентскую выдачу, чего быть не должно ни при каких обстоятельствах. Перейти к разделу «Откат» и не продолжать.

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

Шаг 3б. Остальные пилотные семьи

Только после того, как ИБП прошли сверку выдачи целиком. Порядок такой не из осторожности ради осторожности: если запись членства всё же задевает выдачу, это видно на одной семье, а не на пяти сразу.

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-membership \
  supplier-sync -batch 500 \
  > /srv/tracium/family-pilot/all-membership-dryrun.json

Пустой -family означает «все семьи из источника». Прочитать отчёт по каждой семье так же, как читали ИБП на шаге 2: число канонов, нулевые конфликты, примеры глазами. Семья с подозрительными примерами исключается поимённым списком, а не правкой на ходу: -family ups,vfd,contactor.

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-membership \
  supplier-sync -apply -batch 500 \
  -checkpoint /work/all-membership.cursor \
  > /srv/tracium/family-pilot/all-membership-apply.json
SELECT f.code, count(*) AS members,
       count(*) FILTER (WHERE m.state = 'manual_override') AS manual_rows
FROM product_family_members m
JOIN product_families f ON f.id = m.family_id
GROUP BY f.code ORDER BY f.code;

Ожидание: число членов каждой семьи совпадает с её строкой в отчёте показа, manual_rows = 0 везде. Семья с нулём членов до шага 4 не доходит: спрашивать модель о правилах совместимости не о чем.

Шаг 4. Черновики профилей по пяти семьям

Готовит команда family-profile-draft: собирает снимок фактов семьи, спрашивает LLM-шлюз, разбирает ответ, сохраняет черновик. Публикация ей недоступна ни при каких флагах — статус проставляется литералом.

Сначала показ, без записи:

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-profile-draft \
  charnorm-worker \
  -all -sample 200 -keys 30 \
  > /srv/tracium/family-pilot/profiles-dryrun.json

Соединение открыто только на чтение. -all берёт семьи, у которых есть членство, и не больше пяти за прогон: квота LLM общая с charnorm, matcher и сметой, и прогон по каталогу сжёг бы её молча. Семьи можно назвать и поимённо: -family ups,vfd.

В артефакте по каждой семье — снимок фактов, сырой ответ модели, разобранные оси и предупреждения разбора. Сырой ответ сохраняется даже когда разбор упал: без него неотличимо, модель ответила мусором или мы неправильно прочитали.

Прочитать глазами по каждой семье: роли осей осмысленны, ось полной мощности не перепутана с активной, понижений до score нет либо они объяснимы.

jq -r '.families[] | [.family_code, (.axes|length),
  (if (.blocker // "") == "" then "-" else .blocker end)] | @tsv' \
  /srv/tracium/family-pilot/profiles-dryrun.json

Затем запись, если показ принят:

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-profile-draft \
  charnorm-worker \
  -all -apply \
  > /srv/tracium/family-pilot/profiles-apply.json

Блокер одной семьи не отменяет прогон: остальные обязаны получить черновик. Семья с блокером просто не доходит до шага 6 — публиковать нечего.

Требования к черновику (проверяются кодом, здесь — чтобы знать, что ожидать):

  • происхождение заполнено целиком: автор llm, модель, версия промта;
  • доказательства содержат размер выборки, покрытие, предупреждения разбора и ссылку на прогон;
  • у каждой оси есть обоснование словами;
  • хотя бы одна положительная ось совместимости — иначе запись отвергается на входе (ADR-0076), и это ожидаемое поведение, а не сбой.

Проверка после сохранения:

SELECT f.code, p.version, p.status, p.authored_by, p.llm_model, p.prompt_version
FROM family_compatibility_profiles p
JOIN product_families f ON f.id = p.family_id
ORDER BY f.code, p.version;

Ожидание: по строке на каждую семью, дошедшую до записи, все со status = 'draft', модель и версия промта заполнены. Ни одной строки со status = 'active'.

SELECT count(*) FILTER (WHERE a.characteristic_id IS NOT NULL) AS resolved
FROM family_compatibility_axes a
JOIN family_compatibility_profiles p ON p.id = a.profile_id;

Ожидание: 0. Ссылки на реестр проставляет публикация, а её не было.

Шаг 5. Просмотр черновика человеком

curl -s "https://admin.tracium.ru/api/v1/admin/catalog/families/ups/profiles" \
  -H "Cookie: jwt=$ADMIN_JWT" | jq '.profiles[] | {version, status, authored_by, axes: [.axes[].canonical_key]}'

Читать ручку можно и сервисным токеном; решать — нельзя: утверждение и отклонение требуют опознанного администратора, иначе аудит подписан пустым именем. Это проверяется в тестах и на проде обязано вести себя так же:

curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  "https://admin.tracium.ru/api/v1/admin/catalog/families/ups/profiles/1/approve" \
  -H "Authorization: Bearer $ADMIN_API_KEY"

Ожидание: 403. Если пришло 200 — стоп: аудит решений не защищён.

Шаг 6. Публикация профиля человеком

Решение принимает человек своим входом в админку, не сервисный токен и не эта инструкция. Публикуется столько семей, сколько прошло просмотр; для канарейки достаточно ups.

curl -s --fail-with-body -X POST \
  "https://admin.tracium.ru/api/v1/admin/catalog/families/ups/profiles/1/approve" \
  -H "Cookie: jwt=$ADMIN_JWT" -H 'Content-Type: application/json' \
  -d '{"reason":"пилот ИБП: оси сверены с ADR-0076"}' | jq .

Публикация делает три вещи одной транзакцией: ставит версии статус active, переводит прежнюю активную в superseded и разрешает коды осей в реестр характеристик. Последнее — причина, по которой шаг 4 показывал ноль ссылок.

SELECT f.code, p.version, p.status,
       count(*) FILTER (WHERE a.characteristic_id IS NOT NULL) AS resolved,
       count(*) AS axes
FROM family_compatibility_profiles p
JOIN product_families f ON f.id = p.family_id
LEFT JOIN family_compatibility_axes a ON a.profile_id = p.id
WHERE p.status = 'active'
GROUP BY f.code, p.version, p.status ORDER BY f.code;

Ожидание: у каждой опубликованной семьи resolved = axes. Это подтверждение, а не надежда: публикация разрешает ключи осей все или ни одного — неразрешимый код даёт 400, код, совпавший с несколькими характеристиками, даёт 412, и профиль остаётся черновиком. Расхождение в этой выборке означало бы, что строки правили в обход ручки.

Причина у публикации необязательна (в отличие от отклонения), но названная попадает в аудит: через месяц вопрос «на каком основании включили» упирается именно в неё.

Шаг 7. Пересборка read-model

Read-model решает допустимость кандидата до усечения выдачи. Пока он не пересобран под новую версию профиля, отбор идёт по прежним правилам, а по выдаче это неотличимо от правильной работы.

Показ, без записи:

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-axis-rebuild \
  supplier-sync -all \
  > /srv/tracium/family-pilot/axis-rebuild-dryrun.json

Соединение открывается с default_transaction_read_only: запись невозможна не по договорённости, а по устройству. В отчёте — семья, версия профиля, число членов и число разрешённых осей.

Версия берётся опубликованная, а не наибольшая: наибольшая — это чаще всего свежий черновик, и собранный из него read-model применил бы непринятые правила к живой выдаче. Семья без публикации получает блокер и пропускается. Явно названная -profile-version N принимается как есть — этим откатывают read-model на прежнюю публикацию.

Стоп-условия показа: axes_resolved = 0 или members = 0 у семьи, которую собираются раскатывать. Пустой read-model даёт review на каждую пару — путь формально работает, аналогов нет.

Запись:

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-axis-rebuild \
  supplier-sync -all -apply \
  > /srv/tracium/family-pilot/axis-rebuild-apply.json

Прогон, не создавший ни одной строки, завершается кодом 3. Проверить пропуски:

jq -r '.families[] | [.family_code, .identity_rows, .range_rows,
  .skipped_unresolved, .skipped_unit_mismatch] | @tsv' \
  /srv/tracium/family-pilot/axis-rebuild-apply.json

skipped_unresolved — значения, не разрешившиеся в канон словаря; skipped_unit_mismatch — числа в чужой единице. Эти строки не станут exact никогда, сколько бы раз ни пересобирать: чинится это нормализацией значений, а не пересборкой. Если пропуски съедают большую часть семьи, шаг 8 покажет низкую долю exact — и это будет честный результат, а не сбой.

Шаг 7б. Первое ограниченное обогащение (отдельное решение)

Не входит в путь канарейки и не является предусловием шага 8. Замер честно работает и на сегодняшнем покрытии — просто покажет низкую долю exact. Обогащение поднимает покрытие осей, тратит общую квоту LLM и потому решается отдельно.

Состояние на 2026-08-08, снято командой в режиме чтения:

ВеличинаЗначение
Членов семьи ups3475
Из них хотя бы с одной положительной осью393
Кандидатов (членов без единой положительной оси)3082
Просканировано за прогон (-scan 500)500
Из них достижимы обогащением500 (все — по raw_attributes)
Уже стоят в очереди0
Очередь canonical_deep_enrichment_jobs7 done, 1 superseded, ожидающих нет

Числа снимаются заново одной командой, ничего не меняющей:

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  supplier-sync family-enrich-plan -family ups -scan 500 \
  > /srv/tracium/family-pilot/ups-enrich-plan.json

Что нужно знать до -apply:

  • deep-enrichment-worker НЕ на паузе (WORKER_PAUSED_DEEP_ENRICHMENT_WORKER=false, тик 30 с). Поставленные задания начнут тратить квоту примерно через полминуты после записи — это не отложенная очередь на потом.
  • Одно задание = один вызов LLM по общей квоте с charnorm, matcher и сметой.
  • Команда отказывается писать без -actor и без положительного -limit; жёсткий потолок постановок за прогон — 200, больше не поставить даже опечаткой.
  • title-extract-worker стоит на паузе, и это правильный порядок: сначала дешёвый разбор сырого ответа, потом извлечение из имени отдельной работой.
  • Гейт LLM-шлюза: секция H health-check должна быть зелёной с ключом (200 authenticated, model catalog non-empty). Зелёная проба без ключа доказывает только достижимость и основанием для -apply не является.

Первый проход — ровно 50 заданий:

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  supplier-sync family-enrich-plan -family ups -scan 500 \
  -apply -limit 50 -actor "<имя оператора>" \
  > /srv/tracium/family-pilot/ups-enrich-apply-50.json

Пятьдесят, а не пятьсот: это первый проход по механизму, который на семье массово не применялся ни разу (в очереди за всё время 8 записей, все проверочные). Полсотни хватает, чтобы увидеть долю удачных разборов и цену вызова, и мало, чтобы испортить квоту, если разбор окажется негодным.

Наблюдать до следующего прохода:

SELECT state, count(*) FROM canonical_deep_enrichment_jobs
WHERE created_by = '<имя оператора>' GROUP BY state ORDER BY 1;

Стоп-условия — любое означает не расширять бюджет:

  • доля failed среди 50 больше 10 %;
  • у обработанных канонов не прибавилось строк в canonical_family_axis_lookup после пересборки (шаг 7) — разбор что-то нашёл, но в оси это не превратилось;
  • в логах charnorm-worker или сметы появились отказы LLM: квота общая.

Эффект меряется одним числом — покрытием осей после пересборки read-model:

SELECT ch.code, count(DISTINCT l.canonical_id) AS members_with_axis
FROM canonical_family_axis_lookup l
JOIN characteristics ch ON ch.id = l.characteristic_id
JOIN product_families f ON f.id = l.family_id AND f.code = 'ups'
GROUP BY ch.code ORDER BY 2 DESC;

Расширять бюджет следующего прохода только после того, как это число выросло: задания, не поднимающие покрытие, тратят квоту без результата.

Шаг 8. Теневой замер с гейтами

Замер идёт по тому же пути отбора, что и клиентская выдача, но рычаг семьи опущен: family-shadow-sweep навязывает семейный путь себе и никому больше.

$COMPOSE run --rm --no-deps -T \
  -v /srv/tracium/family-pilot:/work \
  --entrypoint /usr/local/bin/family-shadow-sweep \
  supplier-sync -all \
  -anchors 300 -pool 20 \
  -baseline-pool 2000 -baseline-anchors 25 \
  -gate-exact-share 0.30 \
  -gate-retrieval-p95-ms 400 \
  -gate-empty-pool-share 0.20 \
  -gate-lost-exact-share 0.10 \
  -gate-mode-downgrade-share 0.05 \
  > /srv/tracium/family-pilot/shadow.json
echo "код возврата: $?"

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

ГейтЧто ловит
exact-shareПрофиль строг настолько, что все пары уходят в review
retrieval-p95-msОтбор по семье медленнее категорийного — цена, которую платит клиент
empty-pool-shareЯкоря без кандидатов: выдача пустеет там, где раньше что-то было
lost-exact-shareПредел пула купил задержку ценой ДОКАЗАННЫХ аналогов. Общий recall для порога не годится: предел теряет почти только review
mode-downgrade-shareКандидат остался в списке, но потерял режим: состав такой потери не видит

-baseline-pool 2000 сверяет состав выдачи с потолком кода — тем самым, от которого уходят пределом пула. Сверка поимённая и на одном якоре, поэтому отвечает на вопрос, которого задержка не задаёт: что предел отнял у клиента. Базис дорог, у него собственный бюджет якорей, и сам замер он не урезает.

Замер делается ДВУМЯ прогонами подряд, решение — по второму. Первый прогон после простоя меряет состояние кеша, а не путь: на одном и том же пределе 900 получено 2720 мс холодным прогоном и 61 мс следующим. Гейт задержки, применённый к первому прогону, отвергает исправную настройку.

Код возврата 0 и verdict = "go" — единственное основание идти на шаг 9. Код 3no_go: смотреть gate_failures и blockers по семьям.

jq -r '.verdict, (.families[] | [.family_code, .report.anchors,
  .report.exact_share_complete, .report.retrieval_p95_ms, .report.empty_pool,
  ((.blockers // []) | join(";")),
  ((.gate_failures // []) | map(.name) | join(";"))] | @tsv)' \
  /srv/tracium/family-pilot/shadow.json

empty_pool — счёт якорей, а не доля: делить на anchors приходится глазами, зато видно, на скольких якорях замер вообще состоялся.

Отчёт сохранить рядом с решением о раскатке: через месяц вопрос «почему включили» упирается именно в эти числа.

Шаг 9. Канарейка на одной семье

Только после verdict = "go". Включается ОДНА семья — ups.

9.1. Поднять глобальный рубильник без включённой семьи

Перед этим шагом все family_path_enabled обязаны оставаться false; это проверяется запросом из шага 1. Через утверждённый deploy workflow установить защищённую CI/CD-переменную ANALOGS_FAMILY_PATH_ENABLED=true и выложить только App-VPS. Ручная правка .env на хосте запрещена: следующий deploy её перетрёт.

После рестарта api-server проверить значение в контейнере и повторить снимок трёх канонов скриптом шага 3. mode по-прежнему не должен появиться: глобальный рубильник при нуле включённых семей сам выдачу не меняет.

docker exec <api-server> env | grep '^ANALOGS_FAMILY_PATH_ENABLED=true$'

Стоп-условие: mode появился до включения ups или хоть одна семья уже имеет family_path_enabled = true. Немедленно вернуть CI/CD-переменную в false и повторно выложить App-VPS; ручку семейной раскатки в таком состоянии не вызывать.

9.2. Включить одну семью

curl -s --fail-with-body -X POST \
  "https://admin.tracium.ru/api/v1/admin/catalog/families/ups/rollout" \
  -H "Cookie: jwt=$ADMIN_JWT" -H 'Content-Type: application/json' \
  -d '{"enabled":true,"reason":"канареечный запуск: shadow go, отчёт shadow.json"}' \
  | jq .

Ручка отвергает включение, если у семьи нет опубликованного профиля или её статус не active: рычаг не должен подниматься над пустотой. Переключение и строка аудита пишутся одной транзакцией, имя берётся из входа — поэтому сервисным токеном ручка не работает.

Прямой UPDATE product_families SET family_path_enabled запрещён: он меняет выдачу целой семьи, не оставляя следа кто и зачем.

Наблюдать 30 минут по семье с поднятым рычагом:

  • доля пустых выдач аналогов;
  • p95 ответа GET /v1/canonical/{id}/analogs — того же публичного маршрута, снимки которого сверялись на шаге 3;
  • доля ответов с mode = "review".

Поле mode в ответе — признак того, что подбор пошёл семейным путём. До шага 9 его появление было стоп-условием; после включения рычага оно, наоборот, подтверждает, что канарейка работает.

Немедленный откат — тем же вызовом, без каких-либо предусловий:

curl -s --fail-with-body -X POST \
  "https://admin.tracium.ru/api/v1/admin/catalog/families/ups/rollout" \
  -H "Cookie: jwt=$ADMIN_JWT" -H 'Content-Type: application/json' \
  -d '{"enabled":false,"reason":"откат по инциденту: <что увидели>"}' | jq .

Откат работает и когда семья сломана: профиль вытеснен, статус сброшен, read-model пуст. Рычаг, требующий здорового состояния, бесполезен именно тогда, когда он нужен. Проверить, что рычаг опущен:

curl -s "https://admin.tracium.ru/api/v1/admin/catalog/families/ups/rollout" \
  -H "Cookie: jwt=$ADMIN_JWT" | jq '.events[0]'

Откат

Что откатыватьКакСколько занимает
Клиентская выдача семьиручка раскатки с enabled=falseсекунды
Read-model осейпересобрать по прежней версии: family-axis-rebuild -family ups -profile-version N -applyминуты
Публикация профиляопубликовать прежнюю версию заново; active всегда однаминуты
Черновик профиляreject через ручку с причиной; прямой DELETE не нуженсекунды
Членство семьиDELETE FROM product_family_members m USING product_families f WHERE f.id = m.family_id AND f.code = 'ups' AND m.state = 'auto'; — ручные решения не трогаютсяминуты
Файл продолженияудалить /srv/tracium/family-pilot/ups-membership.cursorсекунды

Схему откатывать нельзя. После шагов 3–4 таблицы уже не пусты, а goose down на общем main затронет и те изменения, что приехали следом. Схема здесь аддитивная: пустые таблицы семей ничего не стоят и никому не мешают.

Откат выдачи не требует отката всего остального. Опущенный рычаг возвращает семью на категорийный путь целиком; членство, профили и read-model при этом никому не мешают и остаются для следующей попытки.

Что может пойти не так

ПризнакПричинаДействие
Семья с нулём кандидатов при непустом каталогеПересобрано дерево, идентификаторы узлов сменилисьСнять разрез категорий заново, перегенерировать 0296 из источника
checkpoint_reset в отчётеМежду прогонами изменилась область или список семейОжидаемое поведение; убедиться, что обход прошёл область целиком
members меньше matchedПрогон прерванПовторить с тем же файлом продолжения; курсор доберёт хвост
В ответе API появилось mode до шага 9Включился family-pathНемедленно опустить рычаг семьи и глобальный киллсвитч, снять замер
Запись отвергнута «черновик не годен»В профиле нет положительной осиВернуть в авторинг; поведение штатное (ADR-0076)
Публикация даёт 400 «ключ оси не разрешается»Код оси придуман моделью, в реестре характеристик его нетОтклонить версию с причиной, перезапустить авторинг; ручное создание характеристики ради публикации запрещено
Публикация даёт 412 «разрешается в несколько»Два кода характеристик различаются регистромЧинить реестр характеристик, а не профиль; выбирать за оператора нельзя
family-axis-rebuild -apply завершился кодом 3Ни одной строки read-model: пропуски съели семьюСмотреть skipped_unresolved и skipped_unit_mismatch; лечится нормализацией значений, не пересборкой
Замер даёт no_go по exact_shareПрофиль строг, либо значения не разрешаются в канонНе ослаблять пороги; вернуть профиль в авторинг или чинить нормализацию
Замер даёт no_go по retrieval_p95_msПул семьи велик, отбор дороже категорийногоПроверить indisvalid индексов шага 1, число членов семьи; раскатку не начинать
После шага 9 доля пустых выдач вырослаRead-model собран под другую версию профиляОпустить рычаг ручкой, пересобрать read-model, повторить замер
Ручка раскатки отвечает «нет активного профиля»Публикации шага 6 не было либо она откатиласьШтатный отказ: рычаг не поднимается над пустотой