AI-інтеграції
У бекенді AI використовується в одному продуктовому сценарії: Support AI. Це чат техпідтримки, який відповідає на основі бази знань із пошуком через pgvector. Довкола нього є адмінка промптів, облік токенів і керування базою знань. Ця сторінка описує, як усе влаштовано в коді. Змінні оточення для AI перелічені в Конфігурації, тут вони не повторюються.
Абстракція провайдерів
Контракт текстового адаптера описано в types.ts: інтерфейс ITextAdapter має isConfigured(), геттер model і єдину операцію streamText(opts, onComplete?). Це async-генератор текстових чанків, тобто генерація завжди потокова. TextStreamOptions містить systemPrompt, userPrompt, temperature, maxOutputTokens, modelOverride і необов'язковий toolCallHandler для tool calling. Після завершення потоку onComplete отримує TokenUsage (promptTokens/completionTokens/totalTokens), назву моделі та finalPrompt, який збирає buildFinalTextPrompt().
| Адаптер | Провайдер / SDK | Tool calling | Usage |
|---|---|---|---|
openai-text-adapter.ts | OpenAI Chat Completions через голий fetch на OPENAI_CHAT_API_URL, SSE (stream: true, include_usage) | Так, один раунд tool calls | Сума usage обох запитів |
xai-text-adapter.ts | xAI через ai (streamText) + @ai-sdk/xai, Responses API (xaiProvider.responses()) | Так, stepCountIs(3) | result.usage |
mistralai-text-adapter.ts | @mistralai/mistralai (client.chat.stream) | Ні, toolCallHandler ігнорується | З останнього чанка, якщо провайдер його надіслав |
Окремо є openai-embedding-adapter.ts на офіційному SDK openai. Він має методи embed(text) і embedBatch(texts) та використовує OPENAI_EMBEDDING_MODEL і OPENAI_EMBEDDING_DIMENSIONS. Embeddings є тільки в OpenAI, альтернативного драйвера немає. Генерації зображень у коді немає.
Вибір адаптера робить ai-provider.ts:
textAdapterє адаптером за замовчуванням. Його обираєAI_TEXT_DRIVER:openai(дефолт),xaiабоmistral. Інше значення зупиняє старт помилкоюInvalid AI configuration: AI_TEXT_DRIVER must be one of openai, xai, mistral(validateRuntimeConfig).getTextAdapterForModel(model)обирає адаптер за назвою моделі з рядка промпту в БД:grokведе до xAI,mistralдо Mistral,gpt/openaiдо OpenAI. Порожнє або невідоме значення повертаєtextAdapter. Модель із промпту потім передається адаптеру якmodelOverride.
Промпти
Промпти зберігаються в таблиці llm_system_prompts (schema.ts): унікальний type, content, і необов'язкові model, temperature, max_tokens. Доступ до таблиці дає prompt-repository.ts (findAll/findByType/update). Контролер працює з трьома типами: SUPPORT_SYSTEM, SUPPORT_FIRST_CHAT, SUPPORT_QUERY_TRANSLATION.
prompt-service.ts завантажує всі рядки в Map на старті (promptService.load() у index.ts) і далі віддає їх синхронно. Кеш має TTL PROMPT_CACHE_TTL_MS (за замовчуванням 60 с): після нього читання ще віддає поточне значення й запускає фонове перечитування, тож зміна промпту на іншому інстансі видна із затримкою не більше TTL; update() на своєму інстансі оновлює кеш одразу. Інвалідації між інстансами (pub/sub) немає. Деталі — Кешування та ETag. Якщо в БД промпту немає, сервіси беруть дефолти: config/support.ts для системного та першого повідомлення, константу в support-query-translation-service.ts для перекладу.
Адмінські маршрути групи prompts (http-routes.ts) проходять через middlewares session_web, csrf_guard, auth_guard, admin_guard. Обробляє їх prompt-controller.ts:
GET /prompts,GET /prompts/:typeчитають промпти з кешу;PUT /prompts/:typeоновлює БД, після чогоpromptService.update()перечитує рядок у кеш;POST /prompts/:type/testпроганяє промпт із довільним повідомленням черезprompt-test-service.tsіtextAdapter— без власногоfetchі без запасного виклику OpenAI. Провайдер не налаштований →503 service_unavailable; провайдер відповів помилкою →502 upstream_error; не вклався вAI_REQUEST_TIMEOUT_MS→504 upstream_timeout. Текст помилки провайдера йде лише в лог. Якщо клієнт закрив запит, виклик провайдера скасовується (HttpContext.signal).
Облік використання
llm-usage-service.ts пише в таблицю llm_text_usage через recordText(); для викликів адаптерів є textUsageRecorder(feature, userId, promptId), що повертає готовий onComplete. Запис іде у fire-and-forget режимі: помилка вставки потрапляє лише в logger.warn. У кожному рядку зберігаються user_id, llm_system_prompt_id, model, feature, три лічильники токенів і відбиток промпту: prompt_sha256 (SHA-256 у hex) та prompt_chars (довжина в символах). Текст промпту (final_prompt) за замовчуванням не зберігається (див. нижче).
Що записується:
feature | Звідки | user_id |
|---|---|---|
SUPPORT_CHAT | відповідь і привітання Support AI (support-service.ts) | користувач чату |
SUPPORT_QUERY_TRANSLATION | переклад запиту перед пошуком (support-query-translation-service.ts) | користувач чату |
SUPPORT_EMBEDDING | embeddings (support-embedding-service.ts): пошук у чаті, індексація статті, reindexAll; final_prompt — вхідний текст, completion_tokens — NULL | користувач для пошуку, NULL для індексації |
PROMPT_TEST | тест промпту з адмінки (prompt-test-service.ts) | адмін |
Після видалення користувача його рядки в llm_text_usage лишаються з user_id = NULL (зовнішній ключ ON DELETE SET NULL): журнал є обліком токенів і вартості, а не даними користувача. Текст промпту (лише з AI_USAGE_STORE_PROMPTS=true) прибирає llm:purge-prompts за віком, незалежно від того, чий це рядок.
Лічильники токенів nullable: NULL означає, що провайдер не повідомив usage, 0 — справжній нуль. Адаптер, який не отримав usage (буває в Mistral і в стрімі OpenAI без include_usage), усе одно викликає onComplete з NULL і пише warn — виклик не зникає з обліку. Рядки, записані до feat-028, мають старі дефолтні 0 замість невідомого значення.
Адмінські GET /admin/usage/text (пагінація з фільтрами userId, dateFrom, dateTo, feature) та GET /admin/usage/stats обслуговує llm-usage-controller.ts; типи рядків — TextUsageRow і TextUsageStatRow у shared (userId і токени — … | null). Статистика групується за model × feature: totalCalls, totalTokens (сума лише за викликами з відомим usage) і callsWithoutUsage. Запити до БД виконує llm-usage-repository.ts.
Прогалини обліку
Вартість не рахується. Колонки ціни немає, є лише токени.
Що журнал зберігає від промпту
Журнал використання — це облік токенів і вартості, а не архів того, що писали користувачі. Промпт містить дослівний текст користувача (а для чату підтримки ще й історію розмови), тому усічення його не захищає: персональні дані можуть бути де завгодно в тексті. Контракт такий:
| Що | Поведінка |
|---|---|
| Збирається за замовчуванням | prompt_sha256 (SHA-256 від UTF-8 тексту), prompt_chars (довжина в символах за кодовими точками, як char_length у PostgreSQL), feature, model, токени. Цього досить, щоб побачити повторюваний промпт і оцінити розмір, але не прочитати його. final_prompt = ''. |
| Текст промпту | Лише з AI_USAGE_STORE_PROMPTS=true (за замовчуванням false; значення читається за контрактом булевих; одруківка, як-от ture, — помилка старту, тож вона не вмикає збирання). Для налагодження промптів на dev/staging. |
| Хто бачить | Адмін через GET /admin/usage/text. Рядок без тексту віддається з finalPrompt: null (а не порожнім промптом), разом із promptSha256 і promptChars; рядки до feat-065 мають null в обох полях, доки їхній текст не очищено. |
| Retention | Зі збереженням тексту — AI_USAGE_PROMPT_RETENTION_DAYS (за замовчуванням 30): старіший текст очищає llm:purge-prompts. Без збереження скрипт очищає все, що лишилося в таблиці. |
Очищення вже записаних даних. pnpm --filter backend llm:purge-prompts [--dry-run] [--all] [--days=<n>] порожнить final_prompt і перед цим дописує prompt_sha256/prompt_chars рядкам, що їх не мали (SHA-256 і довжину рахує сама БД, збігаючись із застосунком). --dry-run лише рахує, --all очищає все незалежно від віку, --days перевизначає retention; невідомий аргумент — помилка (очищення незворотне). Скрипт ідемпотентний; для retention запускайте його з cron чи планувальника, наприклад щодня. Сам по собі фоновий процес у застосунку цього не робить.
Те, що вже записано
Рядки, записані до feat-065, містять повний текст, доки не запущено llm:purge-prompts (один раз після оновлення). Резервні копії БД зберігають їх так само довго, як самі копії.
Support AI і база знань
Статті лежать у support_knowledge_base: title, content, category, is_active, скріншот і колонка embedding vector(1536), яку описує customType у schema.ts. Навіщо потрібне розширення pgvector і як його встановити, див. База даних → pgvector. Розмірність колонки — EMBEDDING_COLUMN_DIMENSIONS = 1536 (embedding-dimensions.ts); OPENAI_EMBEDDING_DIMENSIONS з іншим значенням зупиняє старт: інша розмірність потребує міграції колонки й переіндексації.
Індексація відбувається в support-embedding-service.ts. Текст ${title}\n\n${content} відправляється в OpenAI embeddings, а результат записує setEmbedding() сирим SQL ::vector. Індексацію запускають:
knowledge-base-service.tsпісля створення статті та після зміниtitle/content, у фоні (void ... .catch(log));- адмінські
POST /admin/knowledge-base/:id/reindexіPOST /admin/knowledge-base/reindex-all. Другий маршрут надсилає активні статті пачками поSUPPORT_EMBEDDING_BATCH_SIZE(за замовчуванням 50); POST /admin/knowledge-base/initімпортує статті з файлів (knowledge-base-init-service.ts).
Якщо ключ OpenAI не задано, індексація пропускається з попередженням, а пошук повертає порожній список. clearEmbedding() скидає вектор і оновлює updatedAt одним UPDATE.
Пошук виконує searchByEmbedding() у support-knowledge-repository.ts: косинусна відстань embedding <=> $vec::vector серед активних статей із непорожнім embedding, ORDER BY distance, LIMIT 5, і лише статті з відстанню не більше SUPPORT_SEARCH_MAX_DISTANCE (за замовчуванням 0.8; косинусна відстань від 0 — однаковий зміст — до 2). Якщо релевантних статей немає, контекст порожній, і промпт велить чесно сказати, що відповіді немає. Значення 0.8 не відкалібровано на реальних даних: щоб підібрати своє, залогуйте distance для кількох запитів із відомою відповіддю й візьміть межу трохи вище відстаней правильних статей.
Чат працює через WebSocket (ws-routes.ts, група support, rateLimit 60 запитів за хвилину). Деталі транспорту описано на сторінці WebSocket. support-controller.ts відповідає на запит вердиктом допуску: спершу supportService.admitChat() / admitOpenChat() (ліміт на користувача і замок, див. Допуск повідомлення), і лише тоді { status: 'ok' } та запуск runChat() / runOpenChat() у фоні. Відмова — це сама відповідь на запит (status: '429'), а не ok з подальшою тишею. Токени приходять клієнту подіями support_chat_token, наприкінці надходить support_chat_complete (з screenshots, витягнутими з маркерів [screenshot:id]; клієнт показує їх через GET /support/screenshot/:articleId, який редиректить на CDN_URL, бо медіа чату дозволено лише з нашого CDN) або support_chat_error. Історію читають і видаляють HTTP-маршрути GET/DELETE /support/chat/history.
Потік запиту
Системний промпт збирає support-llm-service.ts. Він підставляє контекст замість {context}, а якщо плейсхолдера немає, дописує контекст у кінець і логує попередження. Окремо додається інструкція відповідати рідною мовою користувача. Невдалий переклад не зупиняє чат: сервіс повертає оригінальний запит.
Історія чату
Історію читають двома різними способами, і жоден не віддає всю розмову (раніше UI отримував її всю, а промпт — молча обрізану):
- Для UI — сторінками.
GET /support/chat/history(support-controller.ts) віддає останню сторінку (за замовчуванням 50 повідомлень,limitвід 1 до 100) за зростанням, а такожhasMore: true, якщо є старіші. Старіші:?before=<id найстарішого повідомлення, яке в клієнта вже є>&limit=…. Курсор —idрядка (цифри без ведучого нуля, ≤ 18 цифр), порядок — заid, а не заcreated_at(рядки, вставлені в одну мілісекунду, мають однаковий час). Некоректніlimit/before—422. Чужі повідомлення недоступні навіть з чужим курсором. На фронтенді кнопка «Show earlier messages» підвантажує попередню сторінку й ставить її перед показаним, не стрибаючи вгору; повідомлення, додані локально й ще не збережені, курсором не стають. - Для промпту — фіксований зріз.
supportRepository.findRecentForContext(userId, 20)(support-repository.ts): останні 20 повідомлень, незалежно від довжини розмови, тож довга розмова не роздуває промпт і вартість. Це окрема константа (SUPPORT_CONTEXT_MESSAGES), а не побічний ефект ліміту сторінки.
Зміна поведінки. Запит без параметрів більше не повертає всю історію: лише останні 50 повідомлень і hasMore. Клієнти, що не знають про hasMore, бачать лише хвіст розмови.
Допуск повідомлення
Кожне повідомлення чату коштує кілька платних викликів провайдера (переклад, embedding, відповідь, а потім ще раз як історія), тому його допускають до того, як відповісти ok. Допуск (support-service.ts, admitChat / admitOpenChat) перевіряє по черзі:
Довжина.
messageне довшеSUPPORT_MESSAGE_MAX_LENGTH= 4000 символів (shared/limits; той самий ліміт уmaxlengthполя вводу на фронтенді). Довше — звичайна помилка валідації WS (validation_failed, кадр помилки) і жодного виклику.Частота на користувача. Атомарний лічильник у Redis (
consumeAuthThrottle):supportChatUser— 20 повідомлень за 60 с,supportOpenChatUser— 5 за 60 с (SUPPORT_CHAT_USER_RATE_LIMIT_*,SUPPORT_OPEN_CHAT_USER_RATE_LIMIT_*, див. Конфігурацію). Понад ліміт — відповідьstatus: "429",code: "rate_limited",reason: "support_rate_limited".Одна відповідь за раз. Замок
support:inflight:<userId>(support-inflight-lock.ts):SET … NX PXзі знаком власника. Поки попередня відповідь пишеться, наступне повідомлення (і привітання теж) —status: "429",reason: "support_busy". Замок знімається скриптом Lua лише якщо в ньому ще токен цього запиту: запит, що втратив замок, не видалить замок наступного.Замок — це оренда (lease), а не припущення про тривалість відповіді: очікування БД у pipeline не обмежені
AI_REQUEST_TIMEOUT_MS, тож жоден фіксований TTL не гарантує, що повільна відповідь не переживе замок. Ключ живе 30 с, а відповідь, поки працює, продовжує його кожні 5 с (Lua:PEXPIREлише якщо токен ще наш). Мертвий процес перестає продовжувати, і користувач вільний за 30 с. Якщо продовження не вдається (Redis мовчить, ключ зник чи належить іншому запиту) або відповідь триває довше за3 × AI_REQUEST_TIMEOUT_MS + 60 с, власник дізнається про втрату раніше, ніж ключ міг би спливти: йогоAbortSignalспрацьовує, виклики провайдера скасовуються, і перед кожним платним кроком (переклад, embedding, відповідь, збереження) є перевірка — друга відповідь поруч не стартує.Добовий бюджет токенів. Кожен користувач має бюджет
AI_SUPPORT_DAILY_TOKEN_BUDGETтокенів на добу (UTC; за замовчуванням 200000;0вимикає облік — лише поза production, при старті production-like середовища з0— помилка). Лічильник — ключllm:budget:<userId>:<YYYY-MM-DD>у Redis (TTL 48 год; журналllm_text_usageлишається аудитом і для рішень не читається). Рішення та альтернативи — ADR 0003.- Резервування (допуск). Після ліміту й замка один Lua-скрипт (llm-budget-service.ts) атомарно перевіряє
витрачено + резерв ≤ ліміті додає резерв; N паралельних запитів не бачать один і той самий залишок. Відмова —status: "429",reason: "support_budget_exceeded",retryAfterSecondsдо півночі UTC; замок, узятий цим допуском, звільняється. Резерв не перевищує ліміт. - Оцінка (support-budget.ts): переклад =
токени(повідомлення) + maxTokens(переклад); embedding =токени(повідомлення); відповідь =AI_SUPPORT_PROMPT_ALLOWANCE_TOKENS(8000: системний промпт, контекст бази знань, історія — їхній розмір на допуску невідомий)+ токени(повідомлення) + maxTokens(відповідь).токени(текст) = ⌈символи / 2⌉— евристика, а не верхня межа: для CJK, emoji, коду співвідношення гірше. Привітання — лише «відповідь». - Звірка (кінець запуску,
release()— перед зняттям замка). Виклик, що не стартував (запуск зупинено: втрачений замок, закрите з'єднання, дублікат повідомлення), повертає свою частку; виклик, що стартував і повідомив usage, списується за фактом (вниз чи вгору); виклик, що стартував без usage (обрив посеред стрімінгу, timeout, помилка провайдера), лишає резерв списаним — скасування після початку генерації не безкоштовне. Помилка звірки не відкликає відповідь: логується, резерв лишається. - Гарантія приблизна: бюджет обмежує вартість на добу на порядок величини, а не з точністю до токена; остання відповідь доби може вийти за ліміт на різницю «факт − резерв», але не більше ніж на одну відповідь (замок). Консервативний резерв означає, що користувач упирається в ліміт трохи раніше за реальні витрати.
- Резервування (допуск). Після ліміту й замка один Lua-скрипт (llm-budget-service.ts) атомарно перевіряє
Якщо Redis недоступний, допуск дає 503 (service_unavailable), а не ok. Це стосується і лічильників ліміту та бюджету (окреме з'єднання redis-rate-limit, що кидає звичайні помилки ioredis): tryInternal розпізнає недоступність обох клієнтів (isRedisUnavailable), тож це не 500.
Замок знімається до фінальної події (support_chat_complete / support_chat_error), тож клієнт, який одразу після відповіді шле наступне повідомлення, не отримає хибне «зайнято».
Збій після допуску (помилка провайдера, таймаут, збій БД) надходить клієнту подією support_chat_error з message і code: chat_failed, open_chat_failed, message_already_received (повтор уже збереженого повідомлення), а коли відповідь зупинено через втрачений замок — chat_interrupted. Закрите з'єднання подію не надсилає.
Ідемпотентний повтор (clientMessageId). Відповідь на запит може загубитися (таймаут, розрив з'єднання), коли сервер уже зберіг повідомлення. Клієнт дає кожному повідомленню clientMessageId ([A-Za-z0-9_-]{1,64}, на фронтенді UUID) і при втраті відповіді шле той самий запит ще раз. Повідомлення зберігається один раз на пару (user_id, client_message_id) — унікальний індекс support_chat_history_client_message_uidx, вставка ON CONFLICT DO NOTHING (паралельний повтор чекає транзакцію оригіналу, потім конфліктує). Повтор проходить допуск як будь-яке повідомлення; якщо повідомлення вже є, відповідь не генерується вдруге, а клієнт отримує support_chat_error з code: "message_already_received". Поки оригінал ще відповідає, повтор отримує 429 support_busy. Без clientMessageId (старі клієнти) кожен запит зберігається окремо.
Порівнювати тексти в історії для з'ясування доставки не можна: оригінальний INSERT може ще виконуватися, коли SELECT уже повернув стару історію. Тому відсутність повідомлення в історії ніколи не означає «не доставлено».
Події відповіді несуть clientMessageId (support_chat_token, support_chat_complete, support_chat_error; у привітання його немає). Фронтенд ігнорує події з чужим id, тож запізнілий complete/error старої відповіді не завершить нову. На сервері запуск, що втратив замок, не надсилає нічого: ні токенів (перевірка перед кожним токеном), ні complete, ні сповіщення (перевірка після збереження відповіді) — лише chat_interrupted, і лише якщо ще міг. INSERT, що вже почався, скасувати не можна: відповідь старого запуску може опинитися в історії поруч із відповіддю нового власника замка.
Клієнт при відмові. Явна відповідь сервера про відмову (429, 503, помилка валідації) або збій до відправки: фронтенд прибирає оптимістично додане повідомлення за його id (привітання могло завершитися після нього й перед відмовою) і повертає текст у поле вводу. Таймаут чи розрив з'єднання — невідомий статус доставки: клієнт один раз шле той самий запит із тим самим id. Прийнято — усе як зазвичай. Не прийнято (навіть явна відмова повтору: оригінал може бути збережений чи ще виконуватися) — повідомлення лишається в розмові, текст у поле не повертається, показується підказка звірити розмову перед новою спробою. Події message_already_received і chat_interrupted змушують клієнт перечитати історію.
Надійність, вартість і безпека
- Таймаут і скасування. Кожен виклик провайдера (OpenAI
fetch, xAIabortSignal, MistralfetchOptions.signal, embeddings{ signal }) отримує сигнал зaiRequestSignal:AI_REQUEST_TIMEOUT_MS(за замовчуванням 60 000 мс) плюс сигнал викликача. Для HTTP цеHttpContext.signal(клієнт відключився), для Support AI — сигнал WS-з'єднання (WsContext.signal, абортиться вonClose): закрита вкладка зупиняє генерацію, і це не логується як збій. AI SDK (xAI) повідомляє про abort не винятком, а частиною стрімуabort; адаптер перетворює її наTimeoutError/AbortError, тож таймаут xAI дає504, а не порожню успішну відповідь. - Помилки. Будь-яка помилка в
runChat()/runOpenChat()ловиться, логується і перетворюється на подіюsupport_chat_errorіз загальним текстом і полемcode(chat_failed,open_chat_failed,chat_interrupted). Ретраїв немає. Скасування через закрите з'єднання подію не надсилає (відповідати нікому). Тест промпту повертає502/503/504, див. вище. - Ліміти на користувача. Додатково до ліміту на IP нижче діють ліміти на користувача, довжина повідомлення і «одна відповідь за раз» (див. Допуск повідомлення).
- Rate limit. Для WS-групи
supportдіє ліміт 60 запитів за хвилину на IP для кожної події окремо (ключ IP + URL маршруту), а не на з'єднання: усі вкладки й пристрої за одним IP ділять цей бюджет. Кожне повідомлення коштує мінімум 3 платні виклики: переклад, embedding і відповідь. HTTP-групиpromptsіadmin/knowledge-baseзакритіadmin_guard, але власногоrateLimitу визначенні групи не мають. - PII. Провайдеру йдуть дослівне повідомлення користувача та до 20 останніх повідомлень історії; фільтрації чи маскування персональних даних немає. У
llm_text_usageцей текст за замовчуванням не потрапляє: лише SHA-256 і довжина (див. Що журнал зберігає від промпту); текст — тільки зAI_USAGE_STORE_PROMPTS=trueі з retention. - Секрети. Ключі задаються тільки через env (
OPENAI_API_KEY=<your-openai-key>тощо).OPENAI_API_KEYспільний для чату й embeddings.
Мова відповіді. Фронтенд передає в support/support_open_chat і support/support_chat мову браузера (language, supportLanguage(); наприклад uk чи pt-BR), а не мову інтерфейсу: інтерфейс перекладено лише en, uk, pl, а Support AI відповідає й іншими мовами з APP_LOCALE_CODES. support-service.ts зводить її до коду з APP_LOCALE_CODES (resolveSupportLanguage), а якщо поле відсутнє чи невідоме — бере SUPPORT_DEFAULT_LANGUAGE (за замовчуванням en). Окремої мови в профілі користувача немає.