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.go—LLMConfig(BaseURL, APIKey, ModelChar, ModelMatch, TimeoutSeconds).client.go—LLMClientinterface +OpenAIClientimpl поверх 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.go—CharNameMappingVO +ComputeInputsHash.domain/repository.go—MappingRepository(FindBySupplierCodeдля hot path,FindByInputsHashдля диагностики/совместимости),UnmappedReader,CharacteristicRawWriterports.domain/prompt.go—PromptBuilder,ParseResponse,PromptVersion=1const.domain/metrics.go—CharnormMetricsinterface +NoopCharnormMetrics.app/normalizer.go—Normalizer.RunBatchuse case.app/metrics.go—AtomicCharnormMetrics.infra/postgres/{mapping_repo,unmapped_reader,characteristic_raw_repo}.go— PG-backed adapters.di.go— fx Module.
backend/internal/core/ingestion/app/orchestrator.go — IngestOrchestratorConfig.CharRawWriter опциональное поле; persist post-Upsert когда offer.ID известен.
backend/internal/core/ingestion/di.go — provides CharacteristicRawWriter.
backend/internal/platform/config/config.go — LLM + 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
- Tier 3 LLM-матчер для probable cases в matching. Следующий цикл — переиспользует
platform/llmбез изменений, добавляет вызовы вmatching/app/matcher.go. - Заполнение
CanonicalProduct.Assignmentsизchar_name_mappings. После Tier 3. - Авто-классификация по ETIM/eCl@ss. Отдельный цикл; ADR-0028 federated identity.
- Cost-tracking в
platform/llm. ЛогированиеPromptTokens × $/1M→ daily budget alert. - Multi-provider fallback на уровне backend’а (если CLIProxy round-robin окажется недостаточным).
- Manual edit support в
char_name_mappings(admin UI для override LLM-решений). - Deprecation
supplier_offers.raw_attributesJSONB после стабилизации (≥1 месяца charnorm в проде). - 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)