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:

  1. Семантически спорные случаи (tie в верхней части chain) должны разрешаться с учётом содержания значений, а не только времени наблюдения.
  2. Разрешение не должно блокировать pipeline: даже если LLM недоступен или лагает, canonical-запись обязана существовать (fallback auto:latest всегда пишется).
  3. Стоимость LLM-вызовов ограничена: повторный вызов только при изменении набора значений.
  4. Низко-уверенные результаты должны попадать в очередь ручной модерации, а не применяться автоматически.
  5. Быстрое отключение без передеплоя (ASSIGNMENT_RESOLVER_ENABLED=false).

Решение

Вариант B: eventual-consistency upgrade

Выбран вариант B — eventual-consistency upgrade поверх работающего pipeline:

  1. Strategy chain всегда пишет auto:latest при tie-break (поведение цикла 2 сохраняется). Canonical-запись никогда не пуста между тиками resolver.

  2. При tie chain параллельно ставит dispute в очередь assignment_dispute_queue через dispute.Enqueuer. Dispute содержит value_set_hash (sha256 от sorted unique values), список кандидатов с трастом, selected_by и текущее winning_value.

  3. LLM-resolver ticker (интервал cfg.AssignmentResolver.TickInterval, default 15min) независимо дренирует pending-споры:

    • Если value_set_hash совпадает с предыдущим resolved-спором → skipped_unchanged.
    • Иначе: строит контекст (canonical name, unit, характеристика + кандидаты) → вызывает LLM (platform/llm CLIProxy → Sonnet) → парсит selected_value + float confidence.
  4. Routing по confidence через трёхуровневый threshold map:

    • high (≥ 0.8) → AssignmentService.ApplyLLMResolution → source auto:llm_resolved.
    • medium (≥ 0.5) → то же, применяется автоматически.
    • low (< 0.5) → запись в assignment_moderation_queue для ручного решения.
  5. Single-writer invariant сохраняется: AssignmentService.ApplyLLMResolution — единственный путь записи source=auto:llm_resolved. Resolver не пишет в canonical_assignments напрямую.

  6. 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 tickercfg.CanonicalAssignment.TickInterval (default 1h production, 5m dev), advisory lock ключ 'canonical_assignment'.
  • Resolver tickercfg.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.Upsert prior-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_ENABLEDtruefalse → ticker не стартует
ASSIGNMENT_RESOLVER_TICK_INTERVAL15mЧастота дренирования
ASSIGNMENT_RESOLVER_MAX_LLM_ATTEMPTS1До перевода в manual_review
ASSIGNMENT_RESOLVER_LLM_MODELgpt-5.4LLM-модель
ASSIGNMENT_RESOLVER_LLM_TIMEOUT180sТаймаут 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)