Агент-аналітик: архітектура
Чат-інтерфейс, який сам пише SQL до ClickHouse — ReAct-агент на LangChain зі стрімінгом через SSE.
Документ описує архітектуру агента-аналітика для маркетологів: чат-інтерфейс, який відповідає на аналітичні питання, сам пише SQL до ClickHouse через власні тули і повертає результати табличками.
Стек: FastAPI, ReAct-агент (create_agent з LangChain 1.0, поверх LangGraph) +
langchain-anthropic (прямий Anthropic API з prompt caching), clickhouse-connect (прямий
HTTP-клієнт до ClickHouse), React-фронт зі стрімінгом через SSE. Дані по креативах доїжджають
з MongoDB у ClickHouse окремим worker’ом.
Загальна схема
┌──────────────┐ SSE ┌─────────────────────────┐
│ UI (React) │ ◄────────────────► │ FastAPI │
│ чат + грід │ POST /chat │ /threads, /messages, │
└──────────────┘ GET /results │ /results/{id} (CSV) │
└───────────┬─────────────┘
│ astream
┌───────────▼─────────────┐
│ ReAct (create_agent) │
│ write_todos (план) │
│ explore_schema (subag.)│
│ checkpointer: MongoDB │
└───────────┬─────────────┘
│ run_sql / render_chart / schema-тули
┌───────────▼─────────────┐
│ ClickHouseGateway │
│ guard → preflight → │
│ execute → CSV persist │
└───────────┬─────────────┘
│ readonly user agent_analyst (HTTP 8123)
┌──────────────┐ kumquat-ch-sync ┌───────────▼─────────────┐
│ MongoDB │ ─────────────────► │ ClickHouse cluster ml │
│ creatives │ Airflow, щогодини │ creative_performance │
└──────────────┘ │ + creatives │
└─────────────────────────┘
Історія рішення: чому пішли з MCP. Перша версія ходила в ClickHouse через sidecar
mcp-clickhouse. Це давало готові тули, але: (1)run_select_queryповертає весь результат у контекст моделі — дорого і шумно; (2) зайвий процес у поді і зайвий hop (HTTP → MCP → HTTP); (3) формат відповіді MCP змінювався між версіями (парсили content-блоки). Замінили на прямийclickhouse-connect+ власнийClickHouseGateway: guard, cost preflight (EXPLAIN ESTIMATE), ліміти сесії і персист повного результату в CSV — модель бачить лише прев’ю.
Агент: ReAct (create_agent) + TodoListMiddleware
Ядро — create_agent з LangChain 1.0 (звичайний ReAct tool-loop на LangGraph) плюс один
middleware — TodoListMiddleware, який дає тул write_todos для планування (з нього UI будує
чекліст плану).
Історія рішення. Перша версія була на
deepagents— harness з плануванням, віртуальною файловою системою і сабагентами. Для нашого профілю задачі (питання → 1–5 SQL-запитів → висновок) з усього цього реально використовувались лишеwrite_todosі AGENT.md; решта (9 вбудованих тулів, ~2.7k токенів схем у кожному LLM-виклику, файли у стейті → важкі чекпоінти, subgraph-стрімінг сабагентів) була чистим оверхедом. Мігрували на голийcreate_agent: планування лишили черезTodoListMiddleware, AGENT.md переїхав у system prompt (статичний префікс краще кешується). Якщо сабагенти знадобляться —deepagents.middleware.subagents.SubAgentMiddlewareпідключається доcreate_agentяк звичайний middleware, без повернення на deepagents.
from langchain.agents import create_agent
from langchain.agents.middleware import TodoListMiddleware
from langchain_anthropic import ChatAnthropic
gateway = ClickHouseGateway(settings.clickhouse, settings.analyst_agent)
agent = create_agent(
model=ChatAnthropic(model_name="claude-sonnet-5", max_tokens_to_sample=4096),
system_prompt=build_system_prompt(semantic), # промпт + AGENT.md + семантичний шар
tools=build_agent_tools(gateway, semantic, settings), # run_sql, validate_sql, lookup_metric,
# render_chart, schema-тули, explore_schema
middleware=[
TodoListMiddleware(), # write_todos → SSE-подія plan
SummarizationMiddleware( # стискає контекст довгих тредів;
model=ChatAnthropic(...), # UI не зачіпає — історія рендериться
trigger=("tokens", 60_000), # з agent__messages, не з graph state
keep=("messages", 20),
),
ToolCallLimitMiddleware(run_limit=20), # м'яка межа: тул блокується, модель
# сама завершує відповідь
AnthropicPromptCachingMiddleware(ttl="1h"), # кеш-брейкпоінти: system + tools +
# хвіст історії; 1h переживає паузи
], # «людина думає між ходами»
checkpointer=mongodb_checkpointer, # треди, продовження діалогу
)
ClickHouseGateway(gateway.py) — єдиний шлях від агента до сховища: guard (sqlglot) → cost preflight (EXPLAIN ESTIMATE, стеля рядків конфігомestimate_row_ceiling) → виконання із session-лімітами (max_execution_time,max_result_rows, … — сервер дозволяє лише знижувати) → персист повного результату в CSV (results_dir, emptyDir у поді; найстаріші файли підчищаються понадresults_max_files).run_sql(sql, purpose)— головний робочий тул:- guard валідує, що це один read-statement (SELECT/WITH/DESCRIBE/SHOW/EXPLAIN), дописує
LIMIT, якщо його нема — convenience layer для швидких, зрозумілих моделі помилок; справжній guardrail — права ClickHouse-юзера (нижче); - у контекст моделі повертає markdown-прев’ю (20 рядків за замовчуванням, модель може
попросити більше через
preview_rows, кап 200) + row/scan-статистику +result_idCSV-файла з повним результатом — не весь результат; - повні дані UI отримує окремою SSE-подією
table(до 1000 рядків) зresult_idдля скачування повного CSV черезGET /api/v1/agent/results/{result_id}.
- guard валідує, що це один read-statement (SELECT/WITH/DESCRIBE/SHOW/EXPLAIN), дописує
validate_sql— перевірка без виконання: guard + план-оцінка вартості. Для запитів по широких вікнах дат — передrun_sql.lookup_metric— канонічна формула метрики з семантичного шару; модель не вигадує власних визначень CPA/ROAS.- Schema-тули (
list_tables,describe_table,show_create_table,profile_column) — інтроспекція черезsystem.*(той самий gateway, без персисту CSV). Основний агент з них отримує лишеshow_create_table(потрібен для дешевих фільтрів перед запитом); решта живе тільки в explorer-сабагенті, щоб списки таблиць і describe-дампи не роздували основний контекст. explore_schema— сабагент schema-explorer: окремийcreate_agentна дешевій моделі (explorer_model, haiku) лише зі schema-тулами. Інтроспекція токеноємна і низько-судженнєва: DDL-дампи і профілі значень не потрапляють в основний контекст — сабагент повертає компактний звіт. Основний агент викликає його як звичайний тул.render_chart— той самий патерн для графіків: модель передає SQL + специфікацію (chart_typeline/bar/area/pie,title,x_key,series), тул сам виконує запит і шле UI подіюchartз готовими для recharts рядками-словниками. Дані йдуть повз контекст моделі — вона бачить лише прев’ю зrun_sql, тому передати великий ряд сама не змогла б. Числові рядки (ClickHouse Decimal/UInt64 приходять через JSON стрінгами) коерсяться у float.- Checkpointer — MongoDB (
langgraph-checkpoint-mongodb): Postgres в інфраструктурі немає, а Mongo вже є. Той самий інстанс, що й основна база kumquat, колекції за неймінг-конвенцією (agent__checkpoints,agent__checkpoint_writes). З версії 0.4 бібліотека лишила тільки синхроннийMongoDBSaver(async-виклики йдуть через executor базового класу) — саме він уanalyst_agent/checkpoint.py. In-memory — тільки для локальної розробки. Checkpointer також обов’язковий для interrupt’ів (clarify) у LangGraph. - Self-correction безкоштовно: помилка ClickHouse повертається як tool result, цикл сам
переписує запит. Ліміти двоярусні:
ToolCallLimitMiddleware(run_limit=20)— м’яка межа з graceful-завершенням,recursion_limit=50— жорсткий бекстоп (має лишати м’якій межі простір спрацювати першою). - Clarify (фаза 2): уточнювальні питання — окремий тул
ask_userчерезinterruptLangGraph (потребує checkpointer, він у нас і так є). UI рендерить interrupt як питання з варіантами.
Модель
LLM — прямий Anthropic API (langchain-anthropic, ключ ANTHROPIC_API_KEY), модель —
конфігом ANALYST_AGENT__MODEL (дефолт claude-sonnet-5; на складних агентних сесіях можна підняти
до Opus, але спершу поміряти якість на реальних питаннях).
Головний важіль економії — prompt caching: system prompt із семантичним шаром + AGENT.md —
великий статичний префікс (~13k символів), що повторюється в кожному кроці агентного циклу.
Кешуванням займається AnthropicPromptCachingMiddleware(ttl="1h"): брейкпоінти на system
prompt, tool definitions і хвості історії; кешоване читання коштує 10% від ціни входу, на
практиці це −50–80% вартості вхідних токенів. TTL 1h (а не дефолтні 5m), бо людина думає над
відповіддю довше 5 хвилин — мертвий кеш дорожчий за дорожчий запис. Звіряти по
cache_read_input_tokens в usage-звіті.
Історія: чому пішли з OpenRouter. Перша версія ходила через OpenRouter (один ключ/біллінг, перемикання провайдерів конфігом), але він бере ~5% комісії при поповненні кредитів, а кешування через посередника — додатковий шар, де воно може мовчки не спрацювати. Після фіксації на Anthropic-only посередник не давав нічого. Відкат назад, якщо знадобиться, — дзеркальний: залежність
langchain-anthropic→langchain-openrouter,ANALYST_AGENT__MODEL, ключ.
Шар даних: ClickHouse
- Прямий клієнт
clickhouse-connect(HTTP 8123), конфіг — спільна секціяCLICKHOUSE__*; юзер/пароль — з секретаanalyst-agent-secrets. - Окремий read-only юзер
agent_analyst— головний guardrail. Python-валідація вguard.py— лише для швидких помилок; зупиняє запис саме ClickHouse. - Doctor-скрипт (
python -m analyst_api.doctor) — preflight проти дрейфу семантичного шару: таблиці/колонки існують, метрики виконуються, джойни валідні, freshness партицій, write-проба відхиляється сервером.--offline— лише синтаксис, для CI; exit code 1 — deploy gate.
За правилами кластера (кореневий CLAUDE.md): всі DDL — ON CLUSTER 'ml', гранти
перевіряти на всіх репліках. Додати в db/init.sql:
-- readonly = 2 дозволяє SELECT і дозволяє клієнту ЗНИЖУВАТИ ліміти,
-- але забороняє INSERT/ALTER/DDL. readonly = 1 заборонив би клієнту
-- передавати будь-які settings взагалі (наш gateway їх шле) — тому 2
-- плюс явні MAX-обмеження, вище яких клієнт піднятись не може.
CREATE SETTINGS PROFILE IF NOT EXISTS agent_analyst_profile ON CLUSTER 'ml' SETTINGS
readonly = 2,
allow_ddl = 0,
max_execution_time = 60 MAX 180,
max_result_rows = 100000 MAX 1000000,
max_result_bytes = 100000000 MAX 1000000000,
max_rows_to_read = 2000000000 MAX 20000000000,
max_bytes_to_read = 200000000000 MAX 2000000000000,
max_memory_usage = 8000000000 MAX 32000000000,
max_threads = 8 MAX 16,
result_overflow_mode = 'break',
timeout_overflow_mode = 'throw',
max_concurrent_queries_for_user = 8;
CREATE USER IF NOT EXISTS agent_analyst ON CLUSTER 'ml'
IDENTIFIED WITH sha256_password BY '<<FROM_SECRETS>>'
SETTINGS PROFILE agent_analyst_profile;
GRANT ON CLUSTER 'ml' SELECT ON kumquat_market_analytics.* TO agent_analyst;
-- Інтроспекція для schema-тулів. Список тримати вузьким; він дзеркалить
-- ALLOWED_SYSTEM_TABLES в app/ch/guard.py.
GRANT ON CLUSTER 'ml' SELECT ON system.tables TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.columns TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.databases TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.parts TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.parts_columns TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.data_skipping_indices TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.table_engines TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.functions TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.numbers TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.one TO agent_analyst;
GRANT ON CLUSTER 'ml' SELECT ON system.query_log TO agent_analyst;
GRANT ON CLUSTER 'ml' SHOW TABLES, SHOW COLUMNS ON kumquat_market_analytics.* TO agent_analyst;
-- Явно відрізаємо exfiltration surface: SOURCES гейтить url(), s3(), file(),
-- remote(), jdbc(). За замовчуванням їх і так нема, але revoke страхує від
-- випадкового успадкування через роль.
REVOKE ON CLUSTER 'ml' SOURCES ON *.* FROM agent_analyst;
REVOKE ON CLUSTER 'ml' INTROSPECTION ON *.* FROM agent_analyst;
-- Обмежити blast radius агентного циклу, що пішов у рознос.
CREATE QUOTA IF NOT EXISTS agent_analyst_quota ON CLUSTER 'ml'
KEYED BY user_name
FOR INTERVAL 1 hour MAX queries = 2000, errors = 200, execution_time = 3600
TO agent_analyst;
-- Перевірка (на КОЖНІЙ репліці, не через Service):
-- SHOW GRANTS FOR agent_analyst;
-- SELECT * FROM system.settings_profile_elements
-- WHERE profile_name = 'agent_analyst_profile';
Семантичний шар
Найважливіша частина для якості відповідей. LLM не вгадає, що «Мета» в цій таблиці записана
як fb (а не facebook), або що CPA рахується по purch, а не по all_purch. Шар — YAML-файли, які збираються
в system prompt. Дві частини: описи таблиць (grain, колонки, граблі, приклади) і канонічні
метрики/розрізи (metrics/dimensions/joins/conventions).
semantic/tables.yaml — опис таблиць
Опис таблиці відповідає на чотири питання, на які не відповідає DESCRIBE TABLE: що таке один
рядок (grain), що означають значення колонок, як правильно рахувати метрики і де граблі.
Описаних таблиць три: creative_performance (атрибуційний фід — гроші, конверсії, усі джерела),
creatives / creatives_current (вимір картки) і tiktok_creative_performance (власний звіт
TikTok — старти відео, 6-секундні перегляди, реакції). Остання не дублює першу: вона є
єдиним місцем, де поміряно саме відео, і водночас має власні spend, impressions і власну
атрибуцію, які з фідом розходяться. Через це в її gotchas окремим рядком стоїть заборона
складати два набори конверсій, а метрика spend отримала явний alias: total_spend — інакше
AS spend збігався б із сирою колонкою нової таблиці й результат був би неоднозначним
(валідатор семантичної моделі таку колізію не пропускає).
version: 1
timezone: Europe/Kyiv
default_database: kumquat_market_analytics
tables:
- name: kumquat_market_analytics.creative_performance
description: >
Щоденна перформанс-статистика рекламних креативів по всіх продуктах
і платформах. Основна таблиця для питань про спенд, закупівлю,
ефективність креативів і роботу дизайн-команди.
# Найважливіший рядок усього опису. Без нього LLM зробить
# SUM(spend_usd) по creo_name і задвоїть цифри, бо не знає,
# що креатив живе в кількох адсетах.
grain: >
Один рядок = (date, campaign_name, adset_name, creo_name).
Той самий креатив зустрічається в багатьох кампаніях/адсетах —
метрики по креативу завжди агрегувати через SUM/GROUP BY.
freshness: "Оновлюється Airflow-DAG щоночі; даних за сьогодні нема, за вчора — можуть бути неповними до ~08:00 UTC."
time_column: date
columns:
- name: date
description: "Дата показу (не дата створення креативу)."
- name: product
description: "Код продукту."
values: {plantme: PlantIn, coins: CoinIn, rockin: RockIn, sportdex: SportDex}
- name: platform
description: "Платформа пристрою: ios / android / web."
- name: media_source
description: >
Рекламне джерело закупівлі. Саме це мають на увазі, коли питають
«по каналах».
values: {fb: "Meta (FB+IG)", tiktok: TikTok, applovin: AppLovin} # інших значень у таблиці немає
- name: creo_name
description: >
Канонічна назва креативу за неймінг-конвенцією
(див. [Workflow](/backend/workflow)); джойниться на creatives.name.
- name: spend_usd
description: "Витрати в USD (єдина валюта таблиці)."
- name: purch
description: "Первинні покупки, атрибутовані креативу. Дефолт для CPA/CR."
- name: all_purch
description: "Усі покупки включно з повторними. Для LTV-питань, не для CPA."
- name: base_purch
description: "Покупки base-плану без апсейлів."
- name: ltv
description: "Прогнозний LTV когорти в USD (модельний, перераховується заднім числом ~60 днів)."
# ... решта колонок за тим самим шаблоном
gotchas:
- "Порожній рядок '' у LowCardinality-колонках означає «невідомо» — NULL там не буває. Фільтрувати country != ''."
- "spend без покупок — норма (рядок є, purchases=0); ділення тільки через nullIf."
- "Тестові кампанії мають префікс 'test_' у campaign_name — виключати за замовчуванням."
- name: kumquat_market_analytics.creatives
description: >
Довідник креативів, синхронізований з MongoDB (kumquat, колекція
creatives). Атрибути креативу: тип, формат хука, джерело, статус
тестування, VLM-розмітка. Для питань «які хуки працюють»,
«чиї креативи найкращі», «AI vs дизайнерські».
grain: "Один рядок = один креатив (name). Оновлюється щогодини."
gotchas:
- "Engine ReplacingMergeTree (ключ — name): історичні версії рядка можуть співіснувати до merge. Читати через argMax(col, updated_at) GROUP BY name або FINAL."
- "vlm_* колонки порожні, поки креатив не пройшов VLM-аналіз."
semantic/metrics.yaml — канонічні метрики і розрізи
CPA можна порахувати чотирма способами (purch, all_purch, base_purch, по інсталах), і всі
виглядають правдоподібно. Канонічні формули прибирають цю варіативність:
metrics:
spend:
sql: sum(spend_usd)
format: currency
tables: [creative_performance]
impressions:
sql: sum(impressions)
tables: [creative_performance]
clicks:
sql: sum(clicks)
tables: [creative_performance]
ctr:
sql: sum(clicks) / nullIf(sum(impressions), 0)
description: Link click-through rate.
format: percent
higher_is_better: true
cpm:
sql: 1000 * sum(spend_usd) / nullIf(sum(impressions), 0)
description: >
Cost per thousand impressions. Зростання CPM при стабільному CTR —
зазвичай тиск аукціону, а не деградація креативу.
format: currency
higher_is_better: false
cpc:
sql: sum(spend_usd) / nullIf(sum(clicks), 0)
format: currency
higher_is_better: false
cpi:
sql: sum(spend_usd) / nullIf(sum(installs), 0)
description: Cost per install.
format: currency
higher_is_better: false
cpa:
sql: sum(spend_usd) / nullIf(sum(purch), 0)
description: >
Cost per purchase, за замовчуванням по purch (первинні покупки,
платформна атрибуція).
format: currency
higher_is_better: false
roas:
sql: sum(ltv) / nullIf(sum(spend_usd), 0)
description: >
Прогнозний, на базі модельного LTV — озвучувати це в кожній відповіді.
format: number
higher_is_better: true
trial_to_purchase_cr:
sql: sum(purch) / nullIf(sum(trials), 0)
format: percent
higher_is_better: true
creative_age_days:
sql: dateDiff('day', min(creatives.launched_at), max(creative_performance.date))
description: Скільки днів креатив у ротації. Fatigue-аналіз крутиться навколо цього.
tables: [creative_performance, creatives]
dimensions:
product:
sql: product
table: creative_performance
platform:
sql: platform
table: creative_performance
media_source:
sql: media_source
table: creative_performance
week:
sql: toMonday(date)
table: creative_performance
description: Тижні починаються з понеділка.
country:
sql: country
table: creative_performance
hook_format:
sql: creatives.vlm_hook_format
table: creatives
creative_source:
sql: creatives.source
table: creatives
performance_tier:
sql: creatives.performance_tier
table: creatives
joins:
- left: kumquat_market_analytics.creative_performance
right: kumquat_market_analytics.creatives
on: creative_performance.creo_name = creatives.name
type: LEFT
conventions:
- >
Ratio-метрики — завжди sum поверх sum. Ніколи не avg() порядкову ставку:
це зважує креатив зі 100 показами так само, як креатив з 10 млн.
- >
При порівнянні креативів — мінімум 1000 показів на креатив, якщо не
просили інакше; застосований поріг озвучувати у відповіді.
- >
creatives — ReplacingMergeTree: у джойнах брати актуальну версію через
argMax(..., updated_at) або FINAL.
- >
Дефолтний період — останні 30 днів, якщо питання не вказує інше.
Завжди фільтрувати по date, щоб працював partition pruning.
- >
campaign_name за замовчуванням НЕ фільтрувати: скоркард рахує всі
кампанії, а маркер тесту стоїть усередині назви —
positionCaseInsensitive(campaign_name, 'test') > 0, не LIKE 'test_%'.
- >
media_source має рівно три значення: fb, tiktok, applovin.
semantic/examples.yaml — few-shot пари
Few-shot пари «питання → SQL» дають більший приріст якості, ніж будь-який тюнінг промпта: агент копіює стиль (nullIf, HAVING від малих вибірок, argMax для creatives, періоди по вчора).
examples:
- question: "Топ-10 креативів по спенду за минулий місяць на Meta"
sql: |
SELECT creo_name,
sum(spend_usd) AS spend,
sum(spend_usd) / nullIf(sum(purch), 0) AS cpa
FROM kumquat_market_analytics.creative_performance
WHERE date >= date_trunc('month', today() - INTERVAL 1 MONTH)
AND date < date_trunc('month', today())
AND media_source = 'fb'
GROUP BY creo_name
ORDER BY spend DESC
LIMIT 10
- question: "Які формати хуків дають найкращий CPA за останні 30 днів?"
sql: |
SELECT c.hook_format,
sum(p.spend_usd) AS spend,
sum(p.spend_usd) / nullIf(sum(p.purch), 0) AS cpa
FROM kumquat_market_analytics.creative_performance AS p
LEFT JOIN (
SELECT name,
argMax(vlm_hook_format, updated_at) AS hook_format
FROM kumquat_market_analytics.creatives
GROUP BY name
) AS c ON p.creo_name = c.name
WHERE p.date >= today() - 30
AND p.date < today()
AND c.hook_format != ''
GROUP BY c.hook_format
HAVING sum(p.impressions) > 1000
ORDER BY cpa ASC
Навіщо кожен блок:
grain— захист від найдорожчої помилки text-to-SQL: подвоєння метрик через неврахований рівень агрегації.valuesз розшифровкою — маркетолог спитає «по Меті», а в данихfb; без мапінгу агент поверне нуль рядків. Саме так це й ламалося: у шарі стоялиfacebook/google/apple/organic, яких у таблиці не існує.metricsз готовим SQL — одна канонічна формула на метрику.conventions— правила, що застосовуються до кожного запиту, а не до однієї таблиці.gotchas— те, що знає тільки команда. Блок росте: кожна знайдена помилка агента перетворюється на рядок тут.
Застереження: семантика
purch/all_purch/base_purchдосі частково припущена — звірити з маркетологами. Помилка в описі гірша за його відсутність: агент буде впевнено помилятися. Значенняmedia_source(fb/tiktok/applovin) і маркер тест-кампанії в назві заміряні по джерелу 2026-08-02 — це вже не припущення.
AGENT.md — контекст команди
Нотатки про команду (конвенції звітності, відомі дані-граблі), які build_system_prompt()
вшиває в system prompt секцією «Team context». Тримати коротко — це в контекст-вікні кожного
ходу і конкурує з самим питанням. Довгоживуче і структурне належить семантичному шару, не сюди.
# Team context
## Хто питає
Перформанс-маркетологи і growth lead. Вільно володіють CPA, ROAS, hook rate;
SQL не пишуть. Пропускай розбір запиту, якщо не просили, але тримай «плівку»
чесною — коли цифра здивує, вони підуть дивитись SQL.
## Конвенції звітності
- Тиждень — понеділок–неділя.
- «Минулий тиждень» = останній повний тиждень пн–нд, не trailing 7 днів.
- «Цей місяць» = month-to-date.
- Валюта — цілі числа від 1 000, два знаки нижче. Рейти — відсотки з одним знаком.
## Відомі граблі даних
- Дані за сьогодні відсутні (нічний DAG); за вчора неповні до ~08:00 UTC.
«Обвал у нуль за вчора» вранці — майже завжди це.
- ltv модельний і перераховується заднім числом ~60 днів — ROAS свіжих когорт
«пливе». Згадувати, коли період < 60 днів.
- creatives.vlm_* порожні для непроаналізованих креативів — «немає розмітки»
≠ «немає хука».
## Стандартні преференції
- Спочатку цифра, потім драйвер, потім застереження.
- Якщо щось схоже на артефакт трекінгу, а не реальну зміну — казати про це
першим, до аналізу.
- Не рекомендувати зміну бюджетів на менш ніж 7 днях даних без прапорця про
розмір вибірки.
Worker: MongoDB → ClickHouse (kumquat-ch-sync)
Атрибути креативів живуть у MongoDB (creatives, див.
packages/kumquat-creatives/src/kumquat_creatives/models.py), а агент читає тільки ClickHouse.
Новий workspace-пакет packages/kumquat-ch-sync з CLI синхронізує колекцію в таблицю
kumquat_market_analytics.creatives.
kumquat-ch-sync --mode=batch інкрементальний догон за watermark (updated_at).
Дефолт: Airflow DAG щогодини. Дешево, надійно. MVP.
kumquat-ch-sync --mode=stream Change Streams, near-realtime. Фаза 2 — для випадку,
коли креатив залили в Kumquat і хочуть бачити його
в аналітиці за хвилину. Потребує replica set;
resume token комітити з WriteConcern("majority").
Таблиця в ClickHouse
Mongo-документ пласкається до колонок, потрібних аналітиці (enum’и → LowCardinality(String),
масиви → Array(String), вкладені блоки vlm.hook.* → vlm_hook_*):
CREATE TABLE IF NOT EXISTS kumquat_market_analytics.creatives ON CLUSTER 'ml'
(
creative_id String, -- Mongo _id hex
name String,
task_type LowCardinality(String),
work_stream LowCardinality(String),
product_id LowCardinality(String),
media_type LowCardinality(String),
source LowCardinality(String),
geo LowCardinality(String),
language LowCardinality(String),
test_status LowCardinality(String),
performance_tier LowCardinality(String),
vlm_hook_format LowCardinality(String),
vlm_hook_script LowCardinality(String),
vlm_body_format LowCardinality(String),
vlm_emotional_driver LowCardinality(String),
vlm_visual_style LowCardinality(String),
launched_at Nullable(DateTime64(3, 'UTC')),
created_at DateTime64(3, 'UTC'),
updated_at DateTime64(3, 'UTC'),
schema_version UInt16
)
ENGINE = ReplicatedReplacingMergeTree(updated_at)
ORDER BY name;
ReplicatedReplacingMergeTree(updated_at)— і replicated (обов’язково для кластера, plain MergeTree дасть розсинхрон реплік), і природні upsert’и: повторна вставка тієї ж версії або новішої безпечна, дедуплікація з перемогою свіжішогоupdated_at.ORDER BY name, а неcreative_id— дедуплікація ReplacingMergeTree йде по ORDER BY-ключу.name— бізнес-ключ, по якому все джойниться (creo_name = creatives.name, unique в Mongo); з ключем по_idсценарій «видалили і залили заново з тим самим name» лишив би два живі рядки на одне ім’я і задвоїв метрики в джойнах. Для JOIN індекс у ClickHouse все одно не використовується (hash join читає праву таблицю повністю) — ключ тут про коректність дедуплікації, а бонусом і первинний індекс дляWHERE name = ....- Читання актуальної версії — через
argMax(..., updated_at)/FINAL; це зафіксовано вconventionsсемантичного шару, щоб агент не рахував по історичних версіях.
Batch-алгоритм
- Watermark —
SELECT max(updated_at) FROM creativesу самому ClickHouse: стан ніде окремо не зберігається, рестарти безкоштовні. - З Mongo читаються документи
updated_at > watermark - overlap(overlap ~10 хв — страховка від clock skew і довгих транзакцій запису). - Вставка блоками; overlap створює дублікати — їх гасить ReplacingMergeTree, а ретраї таски — дедуплікація ідентичних insert-блоків replicated-таблиць. Задача повністю ідемпотентна.
- Hard delete у Mongo watermark не ловить; якщо такі з’являться — щоденна повна звірка
creative_id(окрема легка таска) або soft-delete прапорець у документі.
Оркестрація
DAG у dags/ за існуючим патерном: KubernetesPodOperator, schedule="@hourly",
catchup=False, max_active_runs=1; креденшели Mongo і CH — з kumquat-analytics-secrets.
Ідемпотентність вище означає, що ретраї і пропущені години не потребують спецобробки.
FastAPI
POST /threads → створити тред
POST /threads/{id}/messages → повідомлення, відповідь стрімом (SSE)
GET /threads/{id} → історія
GET /threads/{id}/export → тред як Markdown-звіт (питання, висновки, таблиці, дані графіків)
GET /results/{result_id} → повний результат запиту як CSV (ephemeral, живе до рестарту пода)
- Стрім через SSE (простіше за WebSocket, для чату достатньо). Джерело —
agent.astream(..., stream_mode=["updates", "messages", "custom"])(звичайний LangGraph-граф). - Протокол подій — контракт з UI, фіксується одразу:
{"type": "token", "content": "За останній місяць..."}
{"type": "plan", "todos": [{"text": "...", "status": "done"}]}
{"type": "tool_start", "name": "run_sql", "sql": "SELECT ..."}
{"type": "table", "columns": [...], "rows": [...], "sql": "...", "result_id": "agent-..."}
{"type": "chart", "chart_type": "line", "title": "...", "x_key": "day", "series": ["spend"], "data": [...], "sql": "..."}
{"type": "clarify", "question": "За який період?", "options": [...]}
{"type": "done", "message_id": "..."}
{"type": "error", "detail": "..."}
Ключові рішення:
- Таблиця — окремий тип події, а не markdown у тексті. Агент відповідає
текстом-висновком; сирі дані UI бере зі структурованого результату
run_sql. Надійніше (LLM не переписує числа) і дешевше (повний результат не ганяється через модель).result_idу події — лінк на повний CSV (GET /results/{result_id}). plan— прогрес todo-списку агента (write_todos); довгий аналіз перестає виглядати як завислий спінер.clarify— interrupt з LangGraph; відповідь юзера продовжує граф з checkpointer’а.- SSE тримає з’єднання — потрібні heartbeat-події, інакше проксі в k8s поріже idle-конекшн.
UI
React + Vite, кастомний чат поверх SSE-протоколу:
- текст як markdown; події
table— компонентом-гридом (TanStack Table: сортування, копіювання, експорт CSV); - події
chart— компонент на recharts (ResultChart): line/bar/area/pie зі специфікації, кольори з CSS-змінних теми (--chart-1..5); - під кожною табличкою і графіком — розгортається «показати SQL» (довіра + дебаг);
- блок плану (події
plan) — компактний чекліст над відповіддю; - кнопка «Експорт .md» над тредом — качає
GET /threads/{id}/export; - сайдбар з тредами.
Альтернатива для швидкого MVP — Chainlit, але кастомізація впирається в стелю дуже швидко. Для production-інструмента — одразу свій фронт.
Структура проєкту
Реалізовано в межах backend uv-workspace (не окремим репо-в-репо), UI — сторінка в наявному
Next.js frontend/:
backend/packages/analyst-agent/src/analyst_agent/ # ядро агента, без fastapi
├── agent.py # create_agent(...) + middleware-стек
├── gateway.py # ClickHouseGateway: guard → preflight → execute → CSV persist
├── guard.py # sqlglot-валідація (guard_sql) + ALLOWED_SYSTEM_TABLES
├── tools/ # query.py (run_sql, validate_sql, lookup_metric, render_chart),
│ # schema.py (інтроспекція), explorer.py (сабагент explore_schema)
├── prompts.py # system prompt: інструкції + AGENT.md + рендер семантичного шару
├── semantic/ # typed-модель (pydantic) + tables.yaml, metrics.yaml, examples.yaml
├── doctor.py # preflight-перевірки дрейфу семантичного шару
├── llm.py # фабрика ChatAnthropic
├── streaming.py # astream-чанки → типізовані SSE-події
├── checkpoint.py # MongoDBSaver (agent__checkpoints*)
├── threads.py # метадані тредів (agent__threads)
├── events.py # pydantic-моделі SSE-подій
└── AGENT.md # контекст команди (секція system prompt)
backend/analyst_api/ # окремий FastAPI-процес (Clerk auth з kumquat_api)
├── app.py # lifespan: mongo, checkpointer, gateway (+ping), агент
├── routes.py # /api/v1/agent/threads*, /results/{id}, SSE
├── doctor.py # python -m analyst_api.doctor (CLI-обгортка з settings)
├── sse.py # heartbeat + серіалізація подій
└── dependencies.py # провайдери
backend/packages/kumquat-ch-sync/ # worker Mongo → ClickHouse (uv workspace)
dags/dag_kumquat_ch_sync_creatives.py # Airflow DAG, щогодини
frontend/src/modules/analyst/ # чат: SSE-клієнт, треди, грід, «показати SQL»
k8s/analyst-agent/ # deployment (app + emptyDir для CSV), external-secret
Деплой — у кластер за існуючим патерном: секрети (ANTHROPIC_API_KEY, пароль agent_analyst,
Mongo URI — і для checkpointer’а, і для worker’а) через External Secrets з AWS Secrets Manager,
як зроблено для kumquat-analytics-secrets.
Ризики
- Якість = семантичний шар. Без описів таблиць агент пише синтаксично правильний, але семантично хибний SQL. Основний час закладати сюди, не в граф.
- Семантичний шар не звірений. Формули метрик і значення enum’ів у цьому доці частково припущені — перший крок імплементації, а не деталь.
- Дрейф creatives. Схема Mongo-моделі зміниться — worker має падати голосно (нове
обов’язкове поле), а не тихо писати NULL;
schema_versionв таблиці для контролю. - Контекст-бюджет. AGENT.md + семантичний шар + план + історія — стежити за розміром промпта. Поки семантичний шар статичний, prompt caching робить його майже безкоштовним; якщо він розростеться — виносити описи таблиць у тул «прочитати опис на вимогу» (progressive disclosure).
- Довгі запити. SSE heartbeat обов’язковий; ліміти
agent_analyst_profile— стеля на runaway-запити, квота — на runaway-цикли.