ADR-0049: LLM-resolution для canonical assignments (cycle 3)
Status: accepted Date: 2026-04-30 Deciders: agent-claude
Контекст
Цикл 2 (ADR-0047) ввёл цепочку стратегий trust_weighted → mode → latest для выбора
канонического значения характеристики. Цепочка покрывает большинство случаев, однако при
ничьей (trust_weighted_tie, mode_tie) управление передаётся latest — финальному
tie-break по свежести. Для характеристик с высокой вариативностью поставщиков (single_sharpenable,
некоторые multi_union-кандидаты) выбор «самого свежего» является семантически слабым:
он не опирается на содержание значений.
Требования к циклу 3:
- Семантически спорные случаи (tie в верхней части chain) должны разрешаться с учётом содержания значений, а не только времени наблюдения.
- Разрешение не должно блокировать pipeline: даже если LLM недоступен или лагает,
canonical-запись обязана существовать (fallback
auto:latestвсегда пишется). - Стоимость LLM-вызовов ограничена: повторный вызов только при изменении набора значений.
- Низко-уверенные результаты должны попадать в очередь ручной модерации, а не применяться автоматически.
- Быстрое отключение без передеплоя (
ASSIGNMENT_RESOLVER_ENABLED=false).
Решение
Вариант B: eventual-consistency upgrade
Выбран вариант B — eventual-consistency upgrade поверх работающего pipeline:
-
Strategy chain всегда пишет
auto:latestпри tie-break (поведение цикла 2 сохраняется). Canonical-запись никогда не пуста между тиками resolver. -
При tie chain параллельно ставит dispute в очередь
assignment_dispute_queueчерезdispute.Enqueuer. Dispute содержитvalue_set_hash(sha256 от sorted unique values), список кандидатов с трастом, selected_by и текущее winning_value. -
LLM-resolver ticker (интервал
cfg.AssignmentResolver.TickInterval, default 15min) независимо дренирует pending-споры:- Если
value_set_hashсовпадает с предыдущим resolved-спором →skipped_unchanged. - Иначе: строит контекст (canonical name, unit, характеристика + кандидаты) →
вызывает LLM (
platform/llmCLIProxy → Sonnet) → парситselected_value+ float confidence.
- Если
-
Routing по confidence через трёхуровневый threshold map:
high(≥ 0.8) →AssignmentService.ApplyLLMResolution→ sourceauto:llm_resolved.medium(≥ 0.5) → то же, применяется автоматически.low(< 0.5) → запись вassignment_moderation_queueдля ручного решения.
-
Single-writer invariant сохраняется:
AssignmentService.ApplyLLMResolution— единственный путь записи source=auto:llm_resolved. Resolver не пишет вcanonical_assignmentsнапрямую. -
Admin moderation endpoints для low-confidence и max-attempts-exhausted споров:
GET/POST/DELETE /admin/disputes/...— инспекция и управление очередью.POST /admin/dispute-moderation/{id}/decide— approve/override/reject/escalate.
source enum расширение
Добавлен auto:llm_resolved в CHECK constraint canonical_assignments.source.
Мутируемость: auto:llm_resolved перезаписывается при следующем тике если hash
изменился (так же как auto:latest). manual-источники (cycle 1 convention) живут вечно.
value_set_hash
sha256(sorted(unique(candidate_values))) — вычисляется при enqueue и сравнивается
в DisputeRepo.Upsert prior-state guard. Только resolved-споры с иным hash
повторно открываются. manual_review и in_progress строки не трогаются chain
re-runs: guard защищает их от перезаписи.
State machine спора
pending → in_progress → resolved
→ manual_review (low-conf / max-attempts)
→ superseded (DELETE admin endpoint — terminal)
→ skipped_unchanged (hash match — terminal до нового hash)
Архитектурные подпакеты
catalog/canonical/app/dispute/— types, hash, confidence, prompt, enqueuer, context_builder, resolver, llm_port.catalog/canonical/infra/postgres/— dispute_repo, moderation_repo, dispute_lookup.catalog/canonical/infra/llm/— sonnet_adapter (impl llm_port).catalog/canonical/api/http/— dispute_admin_handler, dispute_moderation_handler.
Паттерн зеркалит P3/P5a/Tier 3: pure-functions подпакет + I/O-порт + инфра-адаптер.
Два тикера в одном бинарнике
canonical-assignment-worker запускает два независимых fx.Invoke scheduler:
- Engine ticker —
cfg.CanonicalAssignment.TickInterval(default 1h production, 5m dev), advisory lock ключ'canonical_assignment'. - Resolver ticker —
cfg.AssignmentResolver.TickInterval(default 15m), advisory lock ключ'canonical_assignment_resolver'.
Тикеры не блокируют друг друга. Отключение resolver через
cfg.AssignmentResolver.Enabled=false → ticker не регистрируется, engine работает.
Принятые ограничения
- Семантическая нормализация (
Вт ↔ кВт,,↔.) по-прежнему вне серии: resolver работает с raw-строками; LLM понимает вариацию неформально. - ETIM/eCl@ss категоризация — roadmap §7; dimension unit-based остаётся категорией цикла 2.
- Только actionable-споры идут в LLM: минимум два разных поставщика и два
разных значения в candidates. Однопоставщичные tie resolver переводит в
supersededдо LLM-вызова. - LLM через platform/llm + CLIProxy; смена модели через
cfg.AssignmentResolver.LLMModel. - Max-attempts по умолчанию 1 перед переводом в manual_review; настраивается
через
cfg.AssignmentResolver.MaxLLMAttempts.
Альтернативы, отклонённые
Вариант A — strict gate (блокирующий LLM)
Strategy chain ждёт ответа LLM прямо внутри тика engine перед записью. Отклонено: при любом LLM-лаге или недоступности canonical-запись остаётся пустой. UX-деградация неприемлема — клиент видит товар без характеристик.
Вариант C — per-characteristic флаг llm_resolvable
Дополнительная колонка на characteristics переключает LLM-разрешение per-характеристика.
Отложено: вариант B достаточен для начала; per-characteristic контроль добавляется
поверх без переделки архитектуры.
Отдельный бинарник dispute-resolver
Выделить resolver в cmd/dispute-resolver. Отклонено: операционные издержки
(второй deployment, синхронизация конфига, отдельный healthcheck) без выгоды —
два тикера в одном процессе достаточно изолированы через advisory lock.
confidence как float без tier enum
Хранить raw float в БД, пороги только в application-коде. Отклонено: tier enum
в assignment_dispute_queue.confidence делает запрос очереди читаемым
(WHERE confidence = 'low') и позволяет Prometheus-метрике иметь bounded label.
LLM всё равно эмитирует float — threshold map переводит его в tier при сохранении.
Последствия
Плюсы:
- Strategy chain всегда пишет → canonical никогда не пуст из-за LLM.
- LLM-стоимость ограничена hash-guard: stable dispute повторно не разрешается.
- Manual moderation queue для low-confidence + exhausted attempts.
auto:llm_resolvedсемантически сильнееauto:latestпри наличии спора.- Engine и resolver работают независимо; отключение resolver не ломает engine.
- Admin API дренирует очередь при инцидентах без ожидания следующего тика.
Компромиссы / accepted trade-offs:
- Canonical-запись может кратковременно содержать
auto:latestдо разрешения resolver (eventual consistency). Нормально для аналитики; не норма для price/availability (те данные не в canonical_assignments). - Два тикера с независимыми locks — небольшой overhead Postgres advisory lock каждые 15min. Принято: advisory lock cheap, contention практически нулевой.
DisputeRepo.Upsertprior-state guard добавляет один SELECT перед каждым UPSERT в engine-тике. Benchmark показал < 1ms в 99th percentile для ожидаемого объёма.
Откат
Пофазовый откат описан в spec §12:
- Быстрое отключение без передеплоя:
ASSIGNMENT_RESOLVER_ENABLED=false(env var) → resolver ticker не регистрируется при старте; engine продолжает работать. Споры накапливаются в pending; admin может дренировать через force-resolve. - Откат только resolver-кода: revert R1–R8 commits, оставить F1–F6 и A1–A3. Таблицы споров остаются, engine enqueuing остаётся активным (безвредно без reader).
- Полный откат цикла 3: revert всех commits цикла 3 +
DROP TABLEчерез новую migration (0058+). Затрагивает только новые таблицы и колонкуauto:llm_resolved.
Реализация
Миграции: 0055 (assignment_dispute_queue), 0056 (assignment_moderation_queue),
0057 (extend canonical_assignments.source CHECK с auto:llm_resolved).
Фазы реализации: F (foundation: 6 задач) → E (engine: 4) → R (resolver: 8) → A (admin/closure: 4). Итого ~22 commits.
Новые env vars:
| Переменная | Дефолт | Назначение |
|---|---|---|
ASSIGNMENT_RESOLVER_ENABLED | true | false → ticker не стартует |
ASSIGNMENT_RESOLVER_TICK_INTERVAL | 15m | Частота дренирования |
ASSIGNMENT_RESOLVER_MAX_LLM_ATTEMPTS | 1 | До перевода в manual_review |
ASSIGNMENT_RESOLVER_LLM_MODEL | gpt-5.4 | LLM-модель |
ASSIGNMENT_RESOLVER_LLM_TIMEOUT | 180s | Таймаут LLM-вызова |
Метрики (cycle 3): 13 counters + 2 histograms в OtelCanonicalMetrics.
Полный список в spec §9.2 / Appendix A.
Связанные документы
- Spec:
docs/superpowers/specs/2026-04-30-canonical-assignments-llm-resolution-design.md - Plan:
docs/superpowers/plans/2026-04-30-canonical-assignments-llm-resolution.md - Roadmap:
docs/superpowers/specs/2026-04-29-canonical-assignments-roadmap.md§5 - Runbook:
docs/operations/canonical-assignment-worker.md - ADR-0044 — LLM gateway (CLIProxy, sashabaranov SDK, platform/llm)
- ADR-0045 — Tier 3 LLM matcher (batched LLM, confidence mapping — паттерн)
- ADR-0046 — Canonical assignments foundation (cycle 1)
- ADR-0047 — Canonical assignments strategies (cycle 2)
- ADR-0048 — Prometheus metrics stack (OtelCanonicalMetrics, loopback :9093)