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 | Ничего: только чтение | — |
| 9 | family_path_enabled ОДНОЙ семьи | Тот же вызов с enabled=false, секунды |
Глобальный ANALOGS_FAMILY_PATH_ENABLED остаётся false до шага 9. На шаге 9
его поднимают через deploy-managed конфигурацию до рычага семьи: пока у
всех семей family_path_enabled = false, это не меняет клиентскую выдачу. Он
нужен как аварийный выключатель всего пути; область клиентского изменения всё
равно задаёт аудируемый рычаг одной семьи.
До шага 9 подбор аналогов идёт прежним категорийным путём. Это не
предположение: пока у семьи опущен рычаг, ResolveFamily возвращает
RolloutOn = false, и pathFor выбирает старый путь.
Предусловия
- В
mainприсутствуют коммиты пилота вплоть до1d214941(пересборка read-model только изactive-профиля). Более ранний образ на шаге 7 может собрать lookup из черновика и применить непринятые правила к живому пути. Выложенный образ собран из того же SHA, что и миграции, — иначе бинарник и схема разойдутся. 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.- Свободного места на
/varDB-VPS — не меньше 20 ГБ (df -h /var). Миграции лёгкие, но запас нужен под WAL. - Окно вне бэкапа:
0295/0296не содержатCONCURRENTLY, но общее правило выкладки миграций сохраняется (см.migrate-on-boot). - Под рукой открыт отчёт отбора: числа «до» нужны для сверки на шаге 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].matched | 3475 на базисе 2026-08-08 |
conflict_count | 0 |
mode | dry-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.jsonSELECT 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.jsonskipped_unresolved — значения, не разрешившиеся в канон словаря;
skipped_unit_mismatch — числа в чужой единице. Эти строки не станут exact
никогда, сколько бы раз ни пересобирать: чинится это нормализацией значений, а
не пересборкой. Если пропуски съедают большую часть семьи, шаг 8 покажет низкую
долю exact — и это будет честный результат, а не сбой.
Шаг 7б. Первое ограниченное обогащение (отдельное решение)
Не входит в путь канарейки и не является предусловием шага 8. Замер честно
работает и на сегодняшнем покрытии — просто покажет низкую долю exact.
Обогащение поднимает покрытие осей, тратит общую квоту LLM и потому решается
отдельно.
Состояние на 2026-08-08, снято командой в режиме чтения:
| Величина | Значение |
|---|---|
Членов семьи ups | 3475 |
| Из них хотя бы с одной положительной осью | 393 |
| Кандидатов (членов без единой положительной оси) | 3082 |
Просканировано за прогон (-scan 500) | 500 |
| Из них достижимы обогащением | 500 (все — по raw_attributes) |
| Уже стоят в очереди | 0 |
Очередь canonical_deep_enrichment_jobs | 7 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.
Код 3 — no_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.jsonempty_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 не было либо она откатилась | Штатный отказ: рычаг не поднимается над пустотой |