01 Overview
Це п'ята стаття серії. У попередніх ми розібрали:
- #1 — чому RAG-довідник не продає, і чим його замінити
- #2 — session state у Postgres як фундамент
- #3 — semantic router на pgvector від 6.7% до 93%
- #4 — 5 n8n bug patterns з фіксами
Тепер збираємо все у один робочий продукт. Sales-агент для puramur.com.ua — 39 нодів у одному n8n workflow, який приймає повідомлення у Telegram, класифікує запит, підбирає товари з живими цінами, будує кошик, збирає контакти і передає менеджеру готове замовлення.
Скорочений вигляд pipeline:
Це виглядає складно на першому знайомстві. Але кожен блок має один зрозумілий job. Плюс — все у одному workflow, без міжсервісних викликів. Latency від трігера до відповіді користувача — 3-5 секунд на середньому запиті.
02 State machine — 8 стадій
Ключова концептуальна різниця між v1 (консультант-довідник) і v2 (sales-агент) — явні стадії воронки. Клієнт не «просто розмовляє з ботом» — він проходить через шляхи. Кожна стадія має вхідні умови, вихідні умови і специфічний prompt.
Entry
Нова сесія (is_new_session=true) або перше повідомлення після довгої паузи.
Job
Привітати, визначити basic intent — це запитання про продукт, консультація по тварині, чи щось інше.
Exit
Автоматичний перехід у discovery при першому змістовному повідомленні (не «привіт»). Якщо клієнт одразу питає ціну — router перехопить у pricing branch, стадія залишиться greeting.
Entry
Після greeting або з presentation коли LLM хоче переуточнити профіль.
Job
Зібрати профіль тварини (type, breed, age, problems) і клієнта (name). Задавати по 1-2 запитання, не робити довгі опитувальники.
Exit
Автоматичний перехід у presentation коли discovery_completeness >= 60. Формула: +25 pet.type, +25 pet.problems, +25 pet.breed, +15 pet.age, +5 pet.name, +5 customer.name.
Entry
discovery_completeness >= 60. Або з cart_building коли клієнт просить показати ще щось.
Job
Показати 1-3 підходящі товари. Використати RAG (expertise + products) + pricing tool для живих цін. Не пропонувати вже показані (state.presented_products).
Exit
У cart_building — коли LLM виявляє buy intent (regex + LLM marker). У discovery — коли LLM вирішує що треба доуточнити (rare).
Entry
Buy intent з presentation. Клієнт сказав «беру», «додай», «оформ» тощо.
Job
Оформити кошик через <cart_action> блок у LLM output. Auto-append подарунка (Brilliant Gloss PR244124) при cart_total >= 1000. Позначити free_delivery при 3000+.
Exit
У contact_collection — коли клієнт підтверджує оформлення («оформл», «давай», «ок»). Назад у presentation — якщо хоче ще показати.
Entry
Confirm intent з cart_building.
Job
Зібрати customer.name і customer.phone. Використовується regex для extraction з message (phone: /\+?3?8?\s?0?\d{2}[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}/). Нормалізація у формат +380XXXXXXXXX.
Exit
У handoff — коли є і name і phone. Це також тригерить одноразове notification менеджеру.
Entry
Одна з умов: (a) contact_collection завершено (name + phone), (b) LLM marker <handoff/>, (c) route=handoff з semantic router, (d) explicit complaint keywords.
Job
Фінальне повідомлення клієнту — «менеджер зв'яжеться протягом 15 хвилин». Idempotent notification у Telegram-канал менеджера з cart summary + contact card.
Exit
Немає. Сесія залишається у handoff до природнього завершення діалогу.
Транзиції прописані у коді, не у промпті. Prompt тільки може emit <next_stage>X</next_stage>, який ми потім валідуємо проти дозволених переходів:
const allowedTransitions = {
greeting: ['greeting', 'discovery'],
discovery: ['discovery', 'presentation'],
presentation: ['presentation', 'discovery', 'cart_building'],
cart_building: ['cart_building', 'presentation', 'contact_collection'],
contact_collection: ['contact_collection', 'handoff'],
handoff: ['handoff'],
};
function validateTransition(currentStage, requestedStage) {
if (!requestedStage) return currentStage;
const allowed = allowedTransitions[currentStage] || [currentStage];
return allowed.includes(requestedStage) ? requestedStage : currentStage;
}
LLM може захотіти перескочити з greeting одразу у cart_building — код не дозволить. Це запобіжник проти нестабільної поведінки моделі і, важливіше, гарантує що будь-хто читаючи workflow бачить реальну граф переходів, а не «що там LLM вирішить».
03 Route Decision — regex extraction pre-LLM
Одна з важливих оптимізацій v2 — витягувати primary дані з повідомлення до виклику LLM. Це robust'ніше і швидше.
// Route Decision (Code node, runOnceForAllItems)
const upstream = $('Initialize State').first().json;
const semanticResult = $('Semantic Router Decision').first().json;
const message = upstream.message_text.toLowerCase();
const state = upstream.state;
// (1) Regex extraction для pet type/breed/problems
const breedPatterns = {
'йорк': 'Yorkshire Terrier',
'кокер': 'Cocker Spaniel',
'британ': 'British Shorthair',
'сфінкс': 'Sphynx',
'мейн-кун': 'Maine Coon',
'персид': 'Persian',
'шпіц': 'Spitz',
'хаск': 'Husky',
'лабрадор': 'Labrador',
// ... 21 pattern всього
};
const problemKeywords = {
'сохне': 'dry_skin',
'лущ': 'dry_skin',
'лине': 'shedding',
'ковтун': 'matting',
'бліх': 'parasites',
'алерг': 'allergy',
'запах': 'odor',
'зуд': 'itching',
// ... 13 keywords всього
};
const extracted = { pet: {}, customer: {} };
// Pet type
if (/\b(кіт|кот|кіш|кот[а-я]*)\b/i.test(message)) extracted.pet.type = 'cat';
else if (/\b(собак|пес|песик|цуцен)\b/i.test(message)) extracted.pet.type = 'dog';
// Breed
for (const [pat, breed] of Object.entries(breedPatterns)) {
if (message.includes(pat)) { extracted.pet.breed = breed; break; }
}
// Problems (multi-match)
const problems = [];
for (const [kw, prob] of Object.entries(problemKeywords)) {
if (message.includes(kw) && !problems.includes(prob)) problems.push(prob);
}
if (problems.length) extracted.pet.problems = problems;
// Phone (Ukrainian formats)
const phoneMatch = message.match(/\+?3?8?\s?0?\d{2}[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}/);
if (phoneMatch) {
const digits = phoneMatch[0].replace(/\D/g, '');
if (digits.length >= 9) {
const normalized = '+380' + digits.slice(-9);
extracted.customer.phone = normalized;
}
}
// Merge extracted у state (не перезаписуємо якщо вже є)
const newPet = { ...state.pet };
if (extracted.pet.type && !newPet.type) newPet.type = extracted.pet.type;
if (extracted.pet.breed && !newPet.breed) newPet.breed = extracted.pet.breed;
if (extracted.pet.problems) {
const existing = new Set(newPet.problems || []);
extracted.pet.problems.forEach(p => existing.add(p));
newPet.problems = [...existing];
}
const newCustomer = { ...state.customer };
if (extracted.customer.phone && !newCustomer.phone) {
newCustomer.phone = extracted.customer.phone;
}
// (2) Пораховуємо discovery_completeness
const completeness =
(newPet.type ? 25 : 0) +
(newPet.problems?.length ? 25 : 0) +
(newPet.breed ? 25 : 0) +
(newPet.age_years ? 15 : 0) +
(newPet.name ? 5 : 0) +
(newCustomer.name ? 5 : 0);
// (3) Auto-transition greeting → discovery якщо є pet.type
let nextStage = state.stage;
if (state.stage === 'greeting' && newPet.type) nextStage = 'discovery';
if (state.stage === 'discovery' && completeness >= 60) nextStage = 'presentation';
const updatedState = {
...state,
pet: newPet,
customer: newCustomer,
discovery_completeness: completeness,
stage: nextStage,
};
return [{
json: {
...upstream,
state: updatedState,
route: semanticResult.route,
route_confidence: semanticResult.confidence,
}
}];
Цей код виконує три речі до того як LLM бачить повідомлення. По-перше, витягує явні факти регексом — надійніше і безкоштовно. По-друге, оновлює discovery_completeness. По-третє, робить auto-transition стадії якщо умови виконані. Це критично важливо — без цього LLM отримує stale stage і генерує не той prompt.
У ранній версії я робив regex extraction після LLM у ноді Extract State Update. Це не працювало для сценарію «клієнт у першому повідомленні написав "У мене сфінкс, сохне шкіра"» — greeting prompt не мав контексту про тварину і задавав «яка у вас тварина?». Клієнт зайвий раз переуточнював. Перенос regex перед Route Decision дав можливість chained transition greeting→discovery→presentation в одному turn для info-rich перших повідомлень.
04 5 route branches у Switch
Після Route Decision повідомлення потрапляє у Switch by Route ноду, яка має 5 виходів. Кожен вихід — окрема гілка обробки з різними інструментами.
| Route | Тул | Prompt-стратегія |
|---|---|---|
pricing |
SQL до pricing view | Compact, з фактами: SKU, name, effective_price, stock_status |
faq |
RAG на kb_faq_view | Answer + 1 line на relevance |
handoff |
Прямий handoff адаптер | Мінімальний prompt: confirm + closing message |
off_topic |
Немає | Redirect prompt: acknowledge → повернути до puramur |
sales |
Залежить від стадії | Stage-specific promt (див. секцію Presentation) |
Sales branch — це основне русло і найскладніша частина workflow. Далі у статті розберемо саме її з deep dive на presentation stage.
05 Presentation branch — RAG + pricing
Це найкомплексніша гілка. Мета — при stage=presentation взяти профіль тварини, знайти релевантні товари через RAG, зважити на живі ціни через SQL tool, і зібрати все у промпт для LLM.
Combined RAG UNION
Замість двох окремих RAG викликів (один для expertise chunks, інший для products chunks) — я об'єднав у один запит через SQL UNION. Це дає більший recall і одразу зважений результат.
-- Combined RAG (Postgres, executeQuery)
WITH query_embedding AS (
SELECT $1::vector AS emb
)
SELECT
'expertise' AS chunk_type,
chunk_id, content, metadata,
1 - (embedding <=> (SELECT emb FROM query_embedding)) AS similarity
FROM kb_expertise_view
WHERE 1 - (embedding <=> (SELECT emb FROM query_embedding)) > 0.35
UNION ALL
SELECT
'product' AS chunk_type,
chunk_id, content, metadata,
1 - (embedding <=> (SELECT emb FROM query_embedding)) AS similarity
FROM kb_products_view
WHERE 1 - (embedding <=> (SELECT emb FROM query_embedding)) > 0.35
ORDER BY similarity DESC
LIMIT 8;
Query embedding готується у попередній ноді з синтетичного query, зробленого з профілю тварини:
const pet = state.pet;
const queryParts = [];
if (pet.type) queryParts.push(pet.type === 'cat' ? 'кіт' : 'собака');
if (pet.breed) queryParts.push(pet.breed);
if (pet.problems?.length) queryParts.push(pet.problems.join(', '));
const ragQuery = queryParts.join(' ');
// Наприклад: "кіт Sphynx dry_skin sensitive_skin"
Отримуємо 8 chunks — суміш expertise (як доглядати за сфінксом з сухою шкірою) і products (конкретні SKU). Далі витягуємо unique SKUs з product chunks:
// Extract SKUs (Code node)
const rows = $input.all().map(i => i.json);
const expertiseChunks = rows.filter(r => r.chunk_type === 'expertise');
const productChunks = rows.filter(r => r.chunk_type === 'product');
// SKUs з metadata product chunks
const skusRaw = productChunks.map(p => p.metadata?.sku).filter(Boolean);
const uniqueSkus = [...new Set(skusRaw)];
// Виключаємо вже показані
const presented = new Set((state.presented_products || []).map(p => p.sku));
const newSkus = uniqueSkus.filter(sku => !presented.has(sku));
return [{
json: {
...upstream,
expertise_chunks: expertiseChunks.slice(0, 3), // топ-3 expertise
candidate_skus: newSkus.slice(0, 5), // до 5 SKU на pricing lookup
}
}];
Call Pricing Tool
Наступна нода викликає pricing_lookup — окремий tool, спроектований у попередній роботі над router (детально у першій статті). Він повертає для кожного SKU: effective_price (з урахуванням акції), stock_status, available_for_cart, available_for_mention, reason_if_unavailable, alternatives.
-- Pricing lookup (Postgres executeQuery)
SELECT
sku, name, category,
price, price_special,
COALESCE(price_special, price) AS effective_price,
status AS stock_status,
CASE
WHEN sku = 'PR244124' THEN FALSE
WHEN status = 'Знято з виробництва' THEN FALSE
WHEN status = 'Очікується' THEN FALSE
WHEN status = 'В наявності' THEN TRUE
ELSE FALSE
END AS available_for_cart,
CASE
WHEN status = 'Знято з виробництва' THEN FALSE
ELSE TRUE
END AS available_for_mention,
CASE
WHEN sku = 'PR244124' THEN 'for_humans_only'
WHEN status = 'Знято з виробництва' THEN 'discontinued'
WHEN status = 'Очікується' THEN 'backorder'
ELSE NULL
END AS reason_if_unavailable
FROM kb_products_view
WHERE sku = ANY($1::text[]);
Це критична частина. Тут кодуються business rules напряму у SQL, а не залишаються на розсуд LLM:
- PR244124 (Brilliant Gloss) — for_humans_only, ніколи не for_cart
- Discontinued — не for_cart і не for_mention
- Backorder — for_mention (можна згадати «очікується»), не for_cart
- В наявності — both
06 Presentation prompt template
Після того як маємо RAG expertise chunks + pricing data + state, будуємо promt. Це найважливіший код у workflow — від нього залежить якість рекомендацій.
// Build Presentation Prompt (Code node)
const state = $('Route Decision').first().json.state;
const expertise = $('Extract SKUs').first().json.expertise_chunks;
const pricingRows = $input.all().map(i => i.json);
// Format expertise block
const expertiseText = expertise.map((e, i) =>
`[E${i+1}] ${e.content}`
).join('\n\n');
// Format products block — з інструкцією для LLM що робити
const productsText = pricingRows.map(p => {
const lines = [
`SKU: ${p.sku}`,
`Назва: ${p.name}`,
`Ціна: ${p.effective_price} грн` +
(p.price_special ? ` (акційна, звичайна ${p.price})` : ''),
`Статус: ${p.stock_status}`,
];
if (!p.available_for_cart && p.reason_if_unavailable) {
lines.push(`⚠️ Недоступно у кошик: ${p.reason_if_unavailable}`);
if (p.reason_if_unavailable === 'for_humans_only') {
lines.push(`⚠️ Це косметика для ЛЮДЕЙ, не пропонуй для тварин`);
}
}
return lines.join('\n');
}).join('\n\n---\n\n');
// State summary для LLM (compact JSON)
const stateBlock = JSON.stringify({
stage: state.stage,
pet: state.pet,
cart: state.cart,
cart_total: state.cart_total,
presented_before: (state.presented_products || []).map(p => p.sku),
});
const systemPrompt = `Ти — консультант puramur.com.ua на стадії PRESENTATION.
ЗАДАЧА: показати клієнту 1-2 підходящих товари з живих цін нижче.
Використовуй експертизу для пояснення ЧОМУ саме ці товари підходять.
СТАН ДІАЛОГУ:
${stateBlock}
ЕКСПЕРТИЗА (використовуй як контекст для пояснень):
${expertiseText}
ЖИВІ ЦІНИ І НАЯВНІСТЬ (не вигадуй, використовуй тільки ці):
${productsText}
ПРАВИЛА:
1. Не пропонуй товари з ⚠️ Недоступно у кошик
2. Обов'язково називай SKU і ціну коли пропонуєш товар
3. Не пропонуй більше 2 товарів у одному повідомленні
4. Якщо клієнт готовий купити — emit
5. Emit cart_building коли є buy intent
6. Emit discovery якщо треба доуточнити профіль
7. Emit для кожного показаного SKU (для tracking)
ФОРМАТ ВІДПОВІДІ:
Природня відповідь клієнту українською. Markers у кінці.`;
return [{ json: { ...state, system_prompt: systemPrompt }}];
Ключове тут — чотири контекстних блоки: state summary, expertise, живі ціни, правила. Кожен окремо. LLM бачить структуровану інформацію, не «стіну тексту», і слідує правилам краще.
Presentation prompt разом з експертизою і цінами — 1200-1800 токенів. У порівнянні з universal prompt v1 (3500 токенів на кожному повідомленні незалежно від контексту) — це майже вдвічі менше, з набагато вищою релевантністю. Гроші економляться на кожному turn, і LLM краще виконує інструкції коли їх мало і вони цільові.
07 Cart operations
Кошик — це JSONB array у session state. Оновлюється через <cart_action> блоки у LLM output, які код парсить і застосовує з business rules.
LLM emit format
// Приклад LLM output після buy intent
Чудовий вибір! Додаю Exotic Spa (PR243587) до вашого кошику —
це наш найм'якший шампунь, ідеально для сфінкса з чутливою шкірою.
За 277 грн ви отримуєте флакон 250мл, вистачить на 2 місяці
регулярного купання.
Хочете додати кондиціонер Delicate Care для щоденного зволоження?
<cart_action type="add" sku="PR243587" qty="1"/>
<presented sku="PR243587"/>
<presented sku="PR243601"/>
<next_stage>cart_building</next_stage>
Cart action parser + gift auto-append
// Extract State Update (Code) — cart section
const llmOutput = $json.output;
const state = $('Route Decision').first().json.state;
// Parse cart_actions
const cartActionRegex = /<cart_action\s+type="(\w+)"\s+sku="([^"]+)"(?:\s+qty="(\d+)")?\/>/g;
const actions = [...llmOutput.matchAll(cartActionRegex)];
let newCart = [...(state.cart || [])];
const pricingLookup = $('Call Pricing Tool').first().json.pricingBySku || {};
for (const [_, type, sku, qtyStr] of actions) {
const qty = parseInt(qtyStr || '1');
const pricing = pricingLookup[sku];
// Guard — не додаємо якщо не available_for_cart
if (type === 'add' && pricing?.available_for_cart) {
const existingIdx = newCart.findIndex(item => item.sku === sku);
if (existingIdx >= 0) {
newCart[existingIdx].qty += qty;
newCart[existingIdx].line_total = newCart[existingIdx].price * newCart[existingIdx].qty;
} else {
newCart.push({
sku,
name: pricing.name,
price: pricing.effective_price,
qty,
line_total: pricing.effective_price * qty,
added_at: new Date().toISOString(),
is_gift: false,
});
}
}
if (type === 'remove') {
newCart = newCart.filter(item => item.sku !== sku);
}
}
// Рахуємо total БЕЗ подарунків
const nonGiftTotal = newCart
.filter(item => !item.is_gift)
.reduce((sum, item) => sum + item.line_total, 0);
// Gift auto-append: PR244124 при cart_total >= 1000
const giftAlready = newCart.some(item => item.is_gift && item.sku === 'PR244124');
if (nonGiftTotal >= 1000 && !giftAlready) {
newCart.push({
sku: 'PR244124',
name: 'Brilliant Gloss (подарунок від 1000₴)',
price: 0,
qty: 1,
line_total: 0,
added_at: new Date().toISOString(),
is_gift: true,
});
} else if (nonGiftTotal < 1000 && giftAlready) {
// Клієнт видалив товар і опустив нижче порогу — забираємо подарунок
newCart = newCart.filter(item => !(item.is_gift && item.sku === 'PR244124'));
}
// Free delivery marker
const freeDelivery = nonGiftTotal >= 3000;
const updatedState = {
...state,
cart: newCart,
cart_item_count: newCart.length,
cart_total: nonGiftTotal,
cart_gift_eligible: nonGiftTotal >= 1000,
cart_free_delivery: freeDelivery,
};
Тут кілька важливих деталей. По-перше, ми перевіряємо available_for_cart навіть якщо LLM emit-нув add — це другий guard проти помилок моделі (перший був у промпті). По-друге, gift auto-append двонаправлений — і додаємо коли перевищили поріг, і забираємо коли опустились нижче (клієнт може видалити товар з кошика). По-третє, cart_total рахується без gift items — інакше поріг 1000 стане підступним, і клієнт з корзиною на 990₴ + подарунок отримає false-positive «free delivery» на 3000+.
08 Contact collection
На стадії contact_collection бот просить ім'я і телефон. Regex extraction на стороні коду підстраховує LLM — якщо клієнт написав телефон навіть у неправильному форматі, ми його розпізнаємо і нормалізуємо.
// Phone extraction і normalization
function extractPhone(text) {
// Ukrainian formats: +380..., 0..., 380...
const pattern = /\+?3?8?\s?0?\d{2}[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}/;
const match = text.match(pattern);
if (!match) return null;
const digits = match[0].replace(/\D/g, '');
if (digits.length < 9) return null;
// Беремо останні 9 цифр (мобільний оператор + номер)
// і додаємо +380
return '+380' + digits.slice(-9);
}
// Name extraction — гнучкий
function extractName(text, currentName) {
if (currentName) return currentName;
// Patterns: "Мене звати X", "Я X", "It's X here"
const patterns = [
/мене\s+звати\s+([А-ЯҐЄІЇа-яґєії\s\-]{2,40})/i,
/я\s+([А-ЯҐЄІЇ][а-яґєії]+)(?:\s+([А-ЯҐЄІЇ][а-яґєії]+))?/,
/моє\s+ім[’'`]я\s+([А-ЯҐЄІЇа-яґєії\s\-]{2,40})/i,
];
for (const p of patterns) {
const m = text.match(p);
if (m) return m[1].trim().split(/\s+/).slice(0, 3).join(' ');
}
return null;
}
Розпізнавання імені завжди складніше за телефон — regex ловить типові конструкції, а на решту (короткі відповіді на кшталт «Микола») сподіваємось на LLM extraction через <customer_name>X</customer_name> маркер у виводі.
Коли і customer.name і customer.phone заповнені — стадія автоматично переходить у handoff:
const hasFullContact = !!(newCustomer.name && newCustomer.phone);
if (state.stage === 'contact_collection' && hasFullContact) {
updatedState.stage = 'handoff';
updatedState.handoff_triggered_at = state.handoff_triggered_at || new Date().toISOString();
updatedState.handoff_reason = state.handoff_reason || 'cart_confirmed';
}
09 Handoff — multi-trigger + idempotency
Handoff тригериться з чотирьох джерел і кожне має свою причину. Це важливо для аналітики (звідки прийшов, з якою корзиною, за який час).
// Handoff detection у Extract State Update
const hasComplaint = /\b(скарг|незадоволен|повернути|верніть|повернен)/i.test(userMessage);
const llmMarker = /<handoff\s*\/?>/i.test(llmOutput);
const routeHandoff = route === 'handoff';
const completedContact = state.stage === 'contact_collection' && hasFullContact;
const shouldTriggerHandoff =
completedContact || hasComplaint || routeHandoff || llmMarker;
let handoffReason = state.handoff_reason;
if (shouldTriggerHandoff && !state.handoff_triggered_at) {
handoffReason =
completedContact ? 'cart_confirmed' :
hasComplaint ? 'complaint' :
routeHandoff ? 'explicit_request' :
'llm_decision';
}
const alreadyNotified = !!state.handoff_triggered_at;
updatedState.handoff_triggered_at =
state.handoff_triggered_at ||
(shouldTriggerHandoff ? new Date().toISOString() : null);
updatedState.handoff_reason = handoffReason;
updatedState.stage = shouldTriggerHandoff ? 'handoff' : state.stage;
// Для наступної ноди IF — знак чи треба нотифікувати
$json.should_notify_manager = shouldTriggerHandoff && !alreadyNotified;
Ключова деталь — alreadyNotified. Це різниця між «handoff вже стався» і «handoff щойно стався у цьому turn». Тільки другий випадок веде до notification менеджера. Це закриває баг з дублюванням alerts коли клієнт пише кілька повідомлень у пост-handoff режимі.
Manager notification payload
// Notify Manager (Telegram sendMessage)
const state = $('Extract State Update').first().json.state;
const customer = state.customer;
const pet = state.pet;
const cart = state.cart.filter(i => !i.is_gift);
const gift = state.cart.find(i => i.is_gift);
const cartText = cart.map(i =>
`• ${i.name} (${i.sku}) × ${i.qty} = ${i.line_total}₴`
).join('\n');
const problems = (pet.problems || []).join(', ') || 'не вказано';
const message = `🔔 НОВИЙ КЛІЄНТ ДЛЯ ОФОРМЛЕННЯ
Причина: ${state.handoff_reason}
Chat ID: ${state.session_id}
━━━ Клієнт ━━━
Ім'я: ${customer.name || 'не вказано'}
Телефон: ${customer.phone || 'не вказано'}
━━━ Тварина ━━━
Тип: ${pet.type || '—'}
Порода: ${pet.breed || '—'}
Проблеми: ${problems}
━━━ Кошик ━━━
${cartText || 'порожній'}
${gift ? \`🎁 Подарунок: ${gift.name}\` : ''}
Сума: ${state.cart_total}₴
${state.cart_free_delivery ? '🚚 Безкоштовна доставка' : ''}`;
// send to manager Telegram chat
return [{ json: { chat_id: MANAGER_CHAT_ID, text: message }}];
Це те що менеджер отримує у Telegram. Готова карточка — ім'я, телефон, профіль тварини, зібраний кошик, знак безкоштовної доставки. Менеджер дзвонить, підтверджує деталі, оформлює замовлення у своїй CRM. Робота бота — привести гарячого клієнта з максимумом контексту.
10 Extract State Update — повний код
Це основна нода яка збирає всі оновлення state з LLM output. Вона запускається після AI Agent і перед UPSERT.
// Extract State Update (Code, runOnceForAllItems)
const llmOutput = $json.output;
const upstream = $('Route Decision').first().json;
const state = upstream.state;
const userMessage = upstream.message_text;
const route = upstream.route;
// (1) Parse markers з LLM output
const nextStageMatch = llmOutput.match(/<next_stage>(\w+)<\/next_stage>/);
const customerNameMatch = llmOutput.match(/<customer_name>([^<]+)<\/customer_name>/);
const customerPhoneMatch = llmOutput.match(/<customer_phone>([^<]+)<\/customer_phone>/);
const presentedMatches = [...llmOutput.matchAll(/<presented\s+sku="([^"]+)"\/?>/g)];
// (2) Extract from user message via regex
const phoneFromMsg = extractPhone(userMessage);
const nameFromMsg = extractName(userMessage, state.customer?.name);
// (3) Merge customer updates (priority: user msg > LLM output > existing)
const newCustomer = { ...state.customer };
if (nameFromMsg && !newCustomer.name) newCustomer.name = nameFromMsg;
else if (customerNameMatch && !newCustomer.name) newCustomer.name = customerNameMatch[1].trim();
if (phoneFromMsg && !newCustomer.phone) newCustomer.phone = phoneFromMsg;
else if (customerPhoneMatch && !newCustomer.phone) newCustomer.phone = customerPhoneMatch[1].trim();
// (4) Presented products tracking
const newPresented = [...(state.presented_products || [])];
const presentedSet = new Set(newPresented.map(p => p.sku));
for (const [_, sku] of presentedMatches) {
if (!presentedSet.has(sku)) {
newPresented.push({
sku,
presented_at: new Date().toISOString(),
stage_iteration: state.stage_iteration,
});
presentedSet.add(sku);
}
}
// (5) Cart operations (див. розділ 07)
// ... код cart parsing і gift auto-append ...
// (6) Handoff detection (див. розділ 09)
// ... код shouldTriggerHandoff ...
// (7) Stage transition з валідацією
const requestedStage = nextStageMatch?.[1];
let nextStage = validateTransition(state.stage, requestedStage);
// Override — auto-transitions з business rules
if (shouldTriggerHandoff) nextStage = 'handoff';
if (nextStage === state.stage) {
// Не переходимо — інкремент iteration
updatedState.stage_iteration = state.stage_iteration + 1;
} else {
// Транзиція — reset iteration і додаємо у history
updatedState.stage_iteration = 0;
updatedState.stage_history = [
...(state.stage_history || []),
{ stage: state.stage, exited_at: new Date().toISOString() }
];
}
// (8) Bot response — LLM output мінус markers
const botResponse = llmOutput
.replace(/<next_stage>[^<]*<\/next_stage>/g, '')
.replace(/<cart_action[^\/>]*\/?>/g, '')
.replace(/<presented[^\/>]*\/?>/g, '')
.replace(/<customer_(name|phone)>[^<]*<\/customer_\1>/g, '')
.replace(/<handoff\s*\/?>/g, '')
.trim();
// Auto-handoff response override
let finalResponse = botResponse;
if (shouldTriggerHandoff && !alreadyNotified) {
finalResponse = buildHandoffResponse(updatedState);
}
return [{
json: {
...upstream,
state: updatedState,
bot_response: finalResponse,
should_notify_manager: shouldTriggerHandoff && !alreadyNotified,
}
}];
Функція buildHandoffResponse формує фінальне повідомлення клієнту з summary кошику:
function buildHandoffResponse(state) {
const cart = state.cart.filter(i => !i.is_gift);
const cartLines = cart.map(i => `• ${i.name} × ${i.qty} — ${i.line_total}₴`).join('\n');
const gift = state.cart.find(i => i.is_gift);
return `Дякую, ${state.customer.name}! Ось що я передаю менеджеру:
${cartLines}
${gift ? `🎁 Подарунок: ${gift.name}` : ''}
Сума: ${state.cart_total}₴
${state.cart_free_delivery ? '🚚 Безкоштовна доставка' : ''}
Менеджер зв'яжеться з вами протягом 15 хвилин на ${state.customer.phone}
для підтвердження і оформлення замовлення. Гарного дня! 🐾`;
}
Це замість того щоб дозволити LLM «щось написати» на handoff — жорсткий шаблон з реальними даними. Клієнт бачить точні цифри, точні продукти, точний телефон куди подзвонять.
11 Reflections
Написання цього workflow забрало більше трьох тижнів після того як v1 було визнано провальним. Це виглядає багато для агента що працює в одній індустрії — але цей самий підхід тепер тиражується у 3-5 разів швидше на інших e-commerce клієнтах, бо шаблони перевірені і закодовані у workflow-скелет.
Головне що я взяв з цієї роботи — AI-агент для продажів це не «AI-задача», це state machine з LLM у ролі генератора тексту. Складність не у промпті. Складність у явних стадіях, exit conditions, tools, data flow, guard rails. Промпт — це один з чотирьох компонентів кожного stage-branch, і у fair балансі він займає може 15-20% зусиль. Решта — код, SQL, structured extraction, state management.
Друга важлива річ — tools рулять качеством. LLM який має pricing_lookup з живими цінами буде продавати. LLM який «шукає ціну у RAG» буде вигадувати. Правильно спроектовані structured tools з business rules всередині SQL знімають з LLM цілі класи потенційних помилок. Це особливо помітно у Brilliant Gloss trap — вирішується одною cursor'ною логікою у pricing tool, а не тритисячним промптом з інструкцією.
Третя — observability з першого дня. Session state у JSONB + кілька SQL views над ним дають те що v1 з Window Buffer Memory не давав ніколи: funnel-аналіз, drop-off метрики, час на стадію, які товари конвертують. Ти буквально бачиш де застряють люди і що з цим робити. Без цього агент — це black box без інструменту оптимізації.
І остання — debugging journey не пропадає. Ті п'ять bug patterns з четвертої статті, ті thresholds з третьої, той session state з другої, ті anti-patterns з першої — все це зараз у моїй голові як checkpoints. Наступний sales-агент буде швидшим і чистішим не тому що n8n став простішим, а тому що я знаю куди дивитись з першого дня. Це те, що ти отримуєш прочитавши всю серію — я віддав тобі два тижні мого debugging безкоштовно, залишилось тільки застосувати.
Якщо ти будуєш AI-агента для e-commerce і хочеш не пройти той самий шлях — цієї серії з п'яти статей достатньо щоб вийти на v2-архітектуру одразу, без v1 фейлу. Session state → semantic router → state machine з явними стадіями → tools з business rules → observability queries → регулярне тестування Bruno. Це весь набір.