ADR-0044: LLM gateway + char-pipeline foundation

Status: accepted Date: 2026-04-29 Deciders: agent-claude

Контекст

В Tracium-коде нет интеграции с LLM. В матчинге реализованы только Tier 1 (точное совпадение по артикулу) и Tier 2 (отпечаток); Tier 3 LLM-валидация осталась нереализованной из spec’а Phase 2 от 21 апреля. Канонические товары создаются шимом без характеристик. Имена характеристик поставщика хранятся в offer_characteristic_raw (миграция 0016 применена), но никем не нормализуются.

Решение

Активируем подмножество Phase 2 spec’а: LLM-инфраструктуру + char-нормализацию.

1. CLIProxy — единый шлюз к LLM

Контейнер eceasy/cli-proxy-api:latest управляет OAuth-токенами Anthropic / Gemini / Codex / Qwen и отдаёт OpenAI-совместимый endpoint. Backend ходит туда через внутренний API-ключ (sk-tracium-dev); прямого pay-per-token к API провайдеров нет.

Pattern скопирован из проекта crewnaut вместе с sidecar’ом alpine/socat для проброса OAuth-callback портов.

2. sashabaranov/go-openai — единственный SDK

CLIProxy эмулирует OpenAI API. Не пишем кастомный HTTP-клиент. Streaming не используется в этом цикле (charnorm — batch JSON-mode).

3. platform/llm — общий пакет

Один интерфейс LLMClient.Chat(ctx, ChatRequest) (ChatResponse, error). Будет переиспользован Tier 3 матчером, AI-нормализацией атрибутов, авто-классификацией по таксономии — все будущие AI-consumer’ы.

4. Charnorm — отдельный процесс

Новый бинарь cmd/charnorm-worker. Свой scheduler.Ticker, свой Postgres advisory lock на ключе charnorm (не пересекается с supplier-sync ключами). Loopback HTTP на 127.0.0.1:9092 для healthz/readyz/metrics.

5. Idempotency и cache через (supplier, supplier_code)

Ключ нормализации имени характеристики поставщика — (supplier, supplier_code). После первого LLM-assisted решения запись сохраняется в char_name_mappings, и следующие ingestion/charnorm тики обязаны использовать её напрямую. Изменение display_name, версии prompt или модели не должно само по себе вызывать новый LLM-запрос для уже известного поля поставщика.

inputs_hash остаётся диагностическим и вспомогательным ключом: это SHA-256 от (supplier, supplier_code, display_name, model, prompt_version), который показывает, каким входом было получено текущее LLM-решение. Основной быстрый путь до LLM-вызова — MappingRepository.FindBySupplierCode.

После Upsert BackfillMappingID идемпотентно (UPDATE … WHERE mapping_id IS NULL) проставляет ссылку всем непомеченным наблюдениям с тем же (supplier, supplier_code). При новой записи в offer_characteristic_raw ingestion также сразу подставляет mapping_id из char_name_mappings, если mapping уже существует.

6. Append-only ingestion

Каждый ingestion tick дописывает наблюдения в offer_characteristic_raw. Существующее supplier_offers.raw_attributes JSONB сохраняется параллельно для backward compat.

Последствия

Плюсы:

  • Подготовлена почва для Tier 3 матчера, AI-нормализации атрибутов, классификации по таксономии — все consumer’ы переиспользуют platform/llm.
  • OAuth-управляемый доступ к LLM позволяет работать через подписки (Claude Pro, Gemini Advanced) вместо API-ключей с pay-per-token. Снижает burn rate.
  • Charnorm-процесс изолирован: падение CLIProxy / истечение OAuth не влияет на ingestion / api-server.
  • (supplier, supplier_code) cache делает повторные тики дешевыми — после первой нормализации поля поставщика LLM не вызывается, а новые raw-наблюдения получают mapping_id сразу при записи.

Минусы / accepted trade-offs:

  • Дополнительный контейнер CLIProxy в production — для production требует мониторинга его доступности (healthcheck в compose уже есть).
  • OAuth-токены живут на хосте через bind-mount .cli-proxy-auth/. Их потеря (host fail) → ручная переавторизация через web UI на 8317.
  • В этом цикле charnorm покрывает только нормализацию имён. Заполнение CanonicalProduct.Assignments — отдельный цикл.

Альтернативы, отклонённые

A. Прямая интеграция с Anthropic API через официальный SDK

Pay-per-token дороже подписки. Нет round-robin между аккаунтами. Сложнее ротация при превышении квот.

B. Локальная LLM (Ollama)

Качество Haiku/Sonnet значительно выше для русскоязычной нормализации электротехнических терминов. Локальная LLM не оправдает себя на текущем объёме.

C. Crewnaut-клиент целиком

Streaming-only. Для batch-нормализации не подходит без расширения. sashabaranov/go-openai даёт стандартный non-streaming + JSON mode из коробки.

Реализация

Затронутые компоненты:

backend/internal/platform/llm/:

  • config.goLLMConfig (BaseURL, APIKey, ModelChar, ModelMatch, TimeoutSeconds).
  • client.goLLMClient interface + OpenAIClient impl поверх sashabaranov.
  • errors.go — 5 sentinels (ErrLLMUnavailable, ErrLLMAuth, ErrLLMRateLimited, ErrLLMBadRequest, ErrLLMTransient).
  • di.go — fx Module.
  • mocks/ — hand-rolled mock для consumer-tests.

backend/internal/core/charnorm/:

  • domain/mapping.goCharNameMapping VO + ComputeInputsHash.
  • domain/repository.goMappingRepository (FindBySupplierCode для hot path, FindByInputsHash для диагностики/совместимости), UnmappedReader, CharacteristicRawWriter ports.
  • domain/prompt.goPromptBuilder, ParseResponse, PromptVersion=1 const.
  • domain/metrics.goCharnormMetrics interface + NoopCharnormMetrics.
  • app/normalizer.goNormalizer.RunBatch use case.
  • app/metrics.goAtomicCharnormMetrics.
  • infra/postgres/{mapping_repo,unmapped_reader,characteristic_raw_repo}.go — PG-backed adapters.
  • di.go — fx Module.

backend/internal/core/ingestion/app/orchestrator.goIngestOrchestratorConfig.CharRawWriter опциональное поле; persist post-Upsert когда offer.ID известен.

backend/internal/core/ingestion/di.go — provides CharacteristicRawWriter.

backend/internal/platform/config/config.goLLM + Charnorm структуры.

backend/cmd/charnorm-worker/main.go — fx composition: platform + charnorm.Module + scheduler + lease + lifecycle hooks.

deploy/docker/docker-compose.yml — 3 новых сервиса (cli-proxy-api, cli-proxy-oauth-relay, charnorm-worker); .cli-proxy-auth/ в gitignore; resources/cli-proxy-api/config.yaml с api-key=sk-tracium-dev.

Deferred items

  1. Tier 3 LLM-матчер для probable cases в matching. Следующий цикл — переиспользует platform/llm без изменений, добавляет вызовы в matching/app/matcher.go.
  2. Заполнение CanonicalProduct.Assignments из char_name_mappings. После Tier 3.
  3. Авто-классификация по ETIM/eCl@ss. Отдельный цикл; ADR-0028 federated identity.
  4. Cost-tracking в platform/llm. Логирование PromptTokens × $/1M → daily budget alert.
  5. Multi-provider fallback на уровне backend’а (если CLIProxy round-robin окажется недостаточным).
  6. Manual edit support в char_name_mappings (admin UI для override LLM-решений).
  7. Deprecation supplier_offers.raw_attributes JSONB после стабилизации (≥1 месяца charnorm в проде).
  8. Streaming chat support в LLMClient — когда понадобится UI чат-функционал.

Ссылки

  • ADR-0030 — Backend DI rule
  • ADR-0028 — Federated identity taxonomies
  • Spec Phase 2 от 21 апреля — docs/superpowers/specs/2026-04-21-phase2-matcher-llm-design.md (partial-superseded в части Step 2.3)
  • Spec этого цикла — docs/superpowers/specs/2026-04-29-charnorm-llm-gateway-design.md
  • Plan этого цикла — docs/superpowers/plans/2026-04-29-charnorm-llm-gateway.md
  • Crewnaut deploy/docker (источник pattern’а CLIProxy)