[Часть1]
Как я строил AI-аналитика для данных о третичных продажахПроблема:У крупного пищевого холдинга накоплена огромная база третичных продаж:
552 млн строк,
53 столбца, 21 месяц данных.
Это sellout из розничных сетей - «Пятёрочка», «МАГНИТ», «ДИКСИ», «ЛЕНТА» и ещё с десяток - с детализацией до конкретного магазина, SKU, цены, промо-активности, типа упаковки и даже признака «халяль»
Аналитики регулярно задают похожие, но каждый раз чуть отличающиеся вопросы:
«Покажи топ-30 варёно-копчёных колбас в Пятёрочке по Москве»,
«Какие творожные продукты растут в продажах?»,
«Где точки роста сырой мясной продукции в Тверской области?»,
«Сформируй матрицу индекса цен по сетям и категориям за год».
Есть ряд типовых задач: от простых top-N выборок до сегментации магазинов и выявления негативных трендов с их причинами.
Каждый такой запрос - это цикл:
понять контекст, написать SQL, проверить фильтры (где Пятёрочка с буквой ё, где МАГНИТ капсом, где Творожная группа вместо Творог), прогнать, оформить выводы.
Обычный пользователь (не познавший SQL кунг фу) не сможет самостоятельно сделать запрос и получить вменяемый результат.
Задача: дать бизнес-пользователю инструмент, где он пишет вопрос на русском языке и получает готовый аналитический ответ с цифрами и выводами.
Ключевой инфраструктурный контекст: Локальная модель gpt-oss-120b поднята на сервере у
Валеры в подвале (да, это жирно для такой задачи, но для скорости решил на большой модельке, потом буду оптимизировать для работы на более мелкой)
Данные о продажах - коммерческая тайна, и отправлять 550 млн строк третичных продаж в облачные API категорически нельзя.
Вся цепочка: LLM, ClickHouse, Telegram-бот (для тестирования выбрал фронтом) живёт на одной площадке, ничего не уходит наружу.
С точки зрения кода это означает, что мы используем OpenAIChatCompletionsModel из OpenAI Agents SDK, но подключаем его к локальному эндпоинту через AsyncOpenAI(base_url=..., api_key=...)
SDK абстрагирует протокол - ему всё равно, что на другом конце не OpenAI, а наш собственный инференс-сервер.
Модель работает с temperature=0 для детерминированности аналитических ответов.
Почему не «один вызов LLM —> один SQL»Первая архитектура была наивной: вопрос → LLM генерирует один SQL → выполняем → отдаём результат.
Быстро стало ясно, что это не работает на реальных задачах.
Проблема в данных.
53 столбца с неочевидной семантикой.
Пятиуровневая товарная иерархия: category -> subcategory -> group_name -> subgroup.
Пользователь скажет «творог», а в данных это subcategory = 'Творожная группа', не 'Творог'.
Скажет «в Пятёрочке», а в столбце client значение 'Пятёрочка' с буквой ё, не 'ПЯТЕРОЧКА'.
Скажет «варёные колбасы», это group_name, не subcategory и не category.
Один неправильный фильтр и запрос возвращает 0 строк, пользователь получает пустой ответ.
А есть и более сложные задачи: «Обозначь точки роста продаж сырой мясной продукции в Тверской области, исключая сезонность».
Тут нужно:
1. Сначала разведать, какие значения есть в данных — какие group_name внутри мясной продукции, что именно в данных соответствует Тверской области.
2. Построить основной запрос с агрегацией по месяцам.
3. Рассчитать тренд роста через lagInFrame() — потому что в ClickHouse нет linearRegression.
4. Отфильтровать сезонные всплески.
Один LLM-вызов с одним SQL этого не потянет.
Архитектура: Agent Loop на OpenAI Agents SDKЯдро - Agent из OpenAI Agents SDK с agent loop и tool calling (можно и на SGR agent core сделать легко):
Telegram User
│
▼
TelegramBotApp ─── typing indicator
│
▼
AnalyticsAgentService
│ Runner.run(agent, input, context, max_turns=25)
▼
Agent Loop (LLM ⇄ Tools, до 25 итераций)
│
├── run_sql → SQL-запрос к ClickHouse
├── get_reference_values → разведка значений полей
│
└── финальный аналитический ответ → Telegram
🔵 Продолжение следует
@alexs_journal