Всем привет! Напомню: в первой статье (https://habr.com/ru/articles/1071580/) я показал дебаунс на Redis, который склеивает дробные сообщения клиента. Во второй (https://habr.com/ru/articles/1074734/) — модуль привязки «чат = сделка» и то, почему вебхуки стоит держать в изолированных ключах. Если первые две части были про архитектуру и концепции, то эта — с обилием кода. Я покажу, как именно вырезаются теги, парсятся телефоны и нарезаются абзацы, потому что без кода это превратится в «я просто настроил ноды».
Обе предыдущие статьи заканчиваются примерно одинаково: «…и текст уходит ИИ-агенту». Это, пожалуй, тот финал, на котором обычно ставят точку и заливают демо на GitHub… Но не в данном случае!)
Дисклеймер: в статье упоминается Meta — организация, признанная экстремистской и запрещённая на территории РФ.
Все ключи, телефоны и названия в примерах изменены; код, схема, логика и скрины- из рабочего проекта заказчика, и строго с его разрешения! Статья не является рекламой/самопиаром и тому подобное.
Между «агент сгенерировал текст» и «клиент получил аккуратное сообщение в директ» лежит сложная прослойка бизнес-логики. Именно этот контур превращает фоновый демо-скрипт в стабильный работающий продукт. Сегодня разберем его по нодам: как «договориться» с LLM о машиночитаемых тегах, настроить гибридный перехват контактов (LLM-tools + регулярки для подстраховки) и нарезать полотно ответа на абзацы по 180 символов.
Стек тот же: self-hosted n8n + Redis, агент на LangChain (DeepSeek 4.0 Pro через OpenRouter, fallback — qwen3.7-plus). Воркфлоу — тот самый, из второй части.
Проблема: сырой вывод агента нельзя отправлять
Вот реальный хвост ответа агента (данные юзера выдуманы):
```textОтлично, контакт принял✉️! Менеджер свяжется с Вами в рабочее время.Принятые данные:•Дата рождения: 12.04.1995•Сфера для улучшения: любовь•Телефон/Связь: +375 29 XXX-XX-XX[VERDICT:ORDER:DATA|HOT] [TASK:NEED_MANAGER_CALL][STAGE:PERSONAL]```
Что с этим не так, если отправить как есть:
Теги. [VERDICT:...] нужен CRM-ветке, но клиент не должен его видеть в диалоге!
Markdown. Системный промпт запрещает **, # и ---, но модель иногда всё равно вставляет разметку. НЕЛЬЗЯgram Direct её не рендерит — клиент видит звёздочки и решётки = визуально в диалоге такое выглядит убого.
Простыня. Ответ до 850 символов одним сообщением на экране телефона выглядит как рассылка, а не как переписка, и тем более она просто нечитаемая. ( P.S. На этапе разработки это сразу заказчику очень не понравилось — я успокоил и пояснил что к запуску будет исправлено).
Отдельно про телефон: пожалуй самая дорогая ошибка — доверить его распознавание LLM на слово. Об этом ниже, на реальном баге.
«Договор» с моделью: протокол тегов вместо JSON mode
Чтобы CRM-ветка понимала, что делать с диалогом, модель договаривается выводить в конце ответа машиночитаемые теги. Прямо в тексте, отдельной строкой, строго в конце!
Возникает вполне логичный вопрос: «Почему теги в тексте, а не JSON mode агента?». Сразу и вкратце поясню по причинам:
-
ответ и метаданные идут одним сообщением — не нужен второй вызов LLM;
-
теги живут в конце и вырезаются простой регуляркой, клиент их не видит;
-
если модель забыла теги, ничего не ломается: сработают дефолты, переписка продолжится, просто CRM-ветка не активируется. Отказ безопасен по построению.
В системном промпте у меня это оформлено как «детерминистическая карта тегов» — LLM не сочиняет вердикт сама, а выбирает из таблицы:
|
Сценарий |
[VERDICT:] |
[TASK:] |
[STAGE:] |
|
Цена браслета |
PRICE:INFO/WARM |
NONE |
PERSONAL |
|
Дал анкету/данные |
ORDER:DATA/HOT |
NEED_MANAG |
PERSONAL |
|
Возражение/»дорого» |
PRICE:OBJECTION/WARM |
NEED_MANAGER_CALL |
PERSONAL |
|
Бронь МК / шоурум |
MK:BOOKING/HOT |
CONFIRM_BOOKIG |
SHOWROOM |
|
Сложный вопрос / ремонт |
SUPPORT:ESCALATE/COLD |
MANAGER_INTERVENE |
CONSULT |
|
Каталог / Контакты / Приветствие |
CATALOG:VIEW\|COLD |
NONE |
COLD |
Поясню следующее: формат вердикта — КАТЕГОРИЯ:ПОДКАТЕГОРИЯ|ТЕМПЕРАТУРА, где температура (HOT/WARM/COLD) — это приоритет лида. Также жёсткое требование к формату вывода: теги — на новой строке в самом конце, без текста после них. Модель это соблюдает стабильно, потому что выбор из фиксированного набора всегда проще свободного формулирования.
Дальше по воркфлоу идут три Code-ноды,которые разбирают этот вывод. Идём по цепочке. по каждой ноде подробнее:
Нода Format Response: «санитайзер» и типограф/копирайтер
Первая нода делает три вещи: чистит markdown, вынимает теги для CRM и готовит текст к отправке в мобильный мессенджер. В ней очистка — это набор скучных, но обязательных замен:
```javascriptlet text = raw .replace(/```[\s\S]*?```/g, '') // код-блоки вырезаем целиком .replace(/\*\*(.*?)\*\*/g, '$1') // жирный: **текст** -> текст .replace(/\*(.*?)\*/g, '$1') // курсив .replace(/#{1,6}\s+/g, '') // заголовки # .replace(/---+/g, '') // разделители .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [текст](ссылка) -> текст .replace(/\r\n|\r/g, '\n') // нормализация переносов```
Порядок здесь важен: теги достаются из текста ДО(!) того, как их вырежут, иначе CRM получит пустоту:
```javascriptconst vMatch = text.match(/\[VERDICT:\s*([^\]]+)\]/i);const tMatch = text.match(/\[TASK:\s*([^\]]+)\]/i);const sMatch = text.match(/\[STAGE:\s*([^\]]+)\]/i);const verdict = vMatch ? vMatch[1].trim() : 'CONSULT';const task = tMatch ? tMatch[1].trim() : 'NONE';// стираем ЛЮБОЙ тег вида [КЛЮЧ: что-угодно],// а не пять конкретных — иначе это игра в догонялки с модельюlet cleanText = text .replace(/\[[A-Z_]+:\s*[^\]]*\]/gi, '') .replace(/\[\s*[A-Z_]+\s*:\s*[^\]]*\]/gi, '') // страховка на пробелы внутри .replace(/[ \t]+/g, ' ') .trim()```
С вырезанием тегов я прошёл нудный путь от «вырезать каждый по имени» (VERDICT, TASK, STAGE, END, FINAL — пять отдельных replace) до универсального «пылесоса». Причина простая: как только в протоколе появляется новый тег, список замен надо не забыть дополнить, а АИ агент однажды изобретает тег, которого нет в списке = и он уезжает юзеру в директ. Регэксп [A-Z_]+:\s*[^\]]* стирает любой тег в верхнем регистре с двоеточием, вторая строка — это страховка на случай пробелов внутри скобок. Тот случай, когда одна строчка заменяет пять и при этом надёжнее всех пяти.
Самое интересное здесь — нарезка абзацев. Лимит 180 символов подобран эмпирически: на экране телефона это три-пять строк, сообщение читается одним взглядом. Но тупой перенос по счётчику ломает списки, поэтому у списков, можно сказать, иммунитет:
```javascriptconst MAX_PARAGRAPH = 180;for (const para of paragraphs) { // элемент списка (1., -, •) не перекраиваем никогда, // даже если он длиннее лимита if (para.match(/^(\d+\.|-|•|\*)/) || para.length <= MAX_PARAGRAPH) { formattedParagraphs.push(para); continue; } // длинный сплошной текст режем только на стыке предложений const sentences = para.split(/(?<=[.!?])\s+(?=[А-Яа-яA-ZЁё])/); // ...набиваем чанки до лимита, как в классическом word wrap}const formattedOutput = formattedParagraphs.join('\n\n');```
(?<=[.!?])\s+ — ретроспективная проверка: разрезаем только после точки, восклицательного или вопросительного знака. На выходе — абзацы, склеенные двойным переносом: Директ показывает их как аккуратные блоки.
Мелочь, которая выдаёт продакшен: дефолты у отсутствующего вердикта в двух соседних нодах разные — здесь CONSULT, в ноде парсинга ниже CHAT_ONLY. Это можно сказать, историческая деталь, на поведение клиента не влияет, но когда будете смотреть статистику «пустых» вердиктов — помните, что дефолта два.
Нода Parse Verdict: структурированные данные из свободного текста
Эта нода вытаскивает из диалога всё, что нужно CRM: имя, дату рождения, телефон, сферу, дизайн. Источников два: текст клиента и «эхо» от АИ агента — по сценарию захвата анкеты модель подтверждает принятые данные, и это подтверждение тоже можно парсить.
Имя клиента. Три паттерна и стоп-лист:
```javascriptconst namePatterns = [ /Имя[:\s]+([А-ЯЁ][а-яё]{2,20})/iu, /(?:^|\n)✔\s*Имя[:\s]*([А-ЯЁ][а-яё]{2,20})/iu, // эхо-подтверждение агента /(?:Меня зовут|зовут|Это)\s+([А-ЯЁ][а-яё]{2,20})/iu];for (const pattern of namePatterns) { const match = userPrompt.match(pattern); // стоп-лист: слова, которые могут встать на место имени if (match && match[1] && !['Все', 'Всё', 'Оплатил', 'Оформил', 'Заказал'].includes(match[1])) { extractedName = match[1]; break; // первый сработавший паттерн выигрывает }}
Стоп-лист появился не сразу. Клиент пишет "это срочно«, АИ агент в подтверждении пишет «Это Мария» = оба случая формально подходят под паттерн "Это + слово с заглавной", и в поле имени уезжают глаголы из сценариев оплаты. Второй рубеж — фоллбэк: если имя так и не нашли, берём первое слово ответа агента и сверяем со словарём «плохих» первых слов (Добрый, Здравствуйте, Стоимость, Доставка…). Менеджер в CRM видит в карточке реальное имя человека, а не «Здравствуйте».
Дата рождения. Регулярка на дд.мм.гггг, сначала ищем в сообщении клиента, потом в «эхе» АИ агента:
```javascriptconst bdayRegex = /\b(\d{2}\.\d{2}\.\d{4})\b/;const bdayInPrompt = userPrompt.match(bdayRegex);// если у клиента нет — смотрим подтверждение агента:raw.match(/(?:дата рождения|д\.р\.?|📅\s*)[: ]*\s*(\d{2}\.\d{2}\.\d{4})/i)```**Сфера и дизайн.** Тут регулярки уже не нужны — работают словари стемов:```javascriptconst SPHERE_SYNONYMS = { 'любовь': ['любов', 'отношен', 'семь', 'брак', 'партн', 'роман'], 'здоровье': ['здоров', 'болез', 'лечен', 'самочувств', 'врач'], 'деньги': ['деньг', 'финанс', 'богат', 'доход', 'зарплат', 'бизнес'], 'удача': ['удач', 'везен', 'счаст', 'успех', 'карьер'], 'защита': ['защит', 'оберег', 'амулет', 'безопасн', 'талисман'], 'развитие': ['развит', 'рост', 'потенциал', 'самопознан', 'мудрост']};function findAllSpheres(text) { if (!text) return []; const lowerText = text.toLowerCase(); const foundSpheres = new Set(); for (const [sphere, keywords] of Object.entries(SPHERE_SYNONYMS)) { for (const keyword of keywords) { if (lowerText.includes(keyword)) { foundSpheres.add(sphere); break; } } } return Array.from(foundSpheres);}// ищем в сообщении клиента; если пусто — в эхе агентаlet extractedSpheres = findAllSpheres(userPrompt);if (extractedSpheres.length === 0) { extractedSpheres = findAllSpheres(raw);}
Стемы вместо целых слов, потому что "любовь", "любовный" и "про любовь" — одна сфера. Находки собираются в Set: клиент может хотеть и любовь, и деньги — это два талисмана.
А вот дефолтов здесь больше нет — и это осознанное решение, и не является недоработкой. Первая версия подставляла общее и унисекс, чтобы поля в CRM не были пустыми. Потом я эти блоки убрал: пустое поле честнее выдуманного. Если АИ агент не распознал сферу, в n8n уедет null и пустой массив, а уже маппинг записи в сделку решает, как это показать менеджеру. Тот же принцип, что и с телефонами ниже: на мой взгляд честная пустота лучше выдуманного значения.
Финал ноды — флаг, который решит «судьбу» диалога:
```javascript// свой дефолт — не такой, как в Format Response выше (там CONSULT)const verdict = vMatch ? vMatch[1].trim() : 'CHAT_ONLY';const task = tMatch ? tMatch[1].trim() : 'NONE';const hasContact = !!(extractedPhone || extractedName || extractedBday);const isHot = verdict.includes('HOT') || verdict.includes('WARM') || task !== 'NONE';const isDialogEnded = raw.includes('END:CONFIRM') || // агент подтвердил захват данных raw.includes('FINAL:SYNC') || // служебный финал синхронизации verdict === 'SUCCESS_PAY' || // оплата (hasContact && isHot) || // есть контакт и лид тёплый task !== 'NONE'; // или поставлена задача
А теперь честное признание: текст клиента эта нода берёт из Redis-ноды, где поле называется promt. Опечатка. Исправлять страшно — на неё завязаны три соседние ноды, так что она официально считается частью архитектуры воркфлоу. (В сниппетах выше userPrompt — это не она, а уже нормализованный текст; опечатка живёт именно в имени поля Redis-ноды.)
Нода Extract Contact: три пути и два предохранителя
Телефон — самая ценная единица данных во всей воронке, и именно тут модель чаще всего врёт. Поэтому извлечение построено как каскад с приоритетами: структурированные данные важнее регулярки, регулярка важнее ничего (!).
Путь 1: TOOL. Агент при захвате анкеты отдаёт телефон отдельным полем инструмента, в структурированном виде. Лучший источник.
Путь 2: PHONE_REGEX. Если тул ничего не дал, сканируем объединённый текст «сообщение клиента + ответ агента».
Путь 3: SOCIAL_REGEX. Телефона нет — ищем телеграм-хэндл через @, t.me/ или telegram.me/.
Каждый источник помечается в поле debug_contact_source (TOOL / PHONE_REGEX / SOCIAL_REGEX) — и это не украшение, а телеметрия, которая однажды сэкономила мне один вечер (об этом ниже).
Нормализация приводит любой формат к единому виду:
```javascript// 80XXXXXXXXX (11 знаков, старый формат) -> 375XXXXXXXXXif (cleanPhone.startsWith('80') && cleanPhone.length === 11) cleanPhone = '375' + cleanPhone.substring(2);// 8XXXXXXXXXX -> 7XXXXXXXXXXelse if (cleanPhone.startsWith('8') && cleanPhone.length === 11) cleanPhone = '7' + cleanPhone.substring(1);// 9-значные номера РБ (29|33|44|25|17) -> +375 + номерconst normalizeByPhone = (digits) => { if (digits.length === 9 && /^(29|33|44|25|17)/.test(digits)) return '375' + digits; return digits;};
А теперь два предохранителя — оба добавлены после того, как сработали на живых данных.
Предохранитель №1: агент принял id чата за телефон. НЕЛЬЗЯgram ID выглядит как длинное число. Однажды модель уверенно вернула его в поле телефона: анкета выглядела идеально, а в CRM уехал идентификатор чата вместо номера. Теперь каждое «структурированное» извлечение сверяется с тем, откуда вообще взят контекст — ключ памяти содержит userId:
```javascriptlet debugSource = 'NONE'; // метка источника контакта: TOOL / PHONE_REGEX / SOCIAL_REGEX// senderId достаём из ключа памяти чата вида ig:<userId>if (senderId && parsedPhone === senderId) { cleanPhone = null; debugSource = 'SENDER_ID_BLOCKED'; // и видим это в логе выполнения}
Предохранитель №2: не ловить собственные номера. В скриптах агента есть телефон ателье — он же в ответах на вопрос «как с вами связаться?». Сканируя объединённый текст «клиент + ответ агента», легко принять свой же контакт за номер телефона клиента:
```javascriptconst BLACKLIST = { phones: ['79001234567', '375291234567'], // в статье номера вымышленные telegram: ['@brand_bot', '@brand']};
После обоих предохранителей в CRM попадает либо нормализованный телефон, либо @хэндл, либо честная пустота. От себя, могу сказать что честная пустота лучше выдуманного номера: менеджер видит, что контакта нет, и не звонит по несуществующему номеру.
Развилка: ответить в директ или тащить в CRM
Дальше простая IF нода с тремя условиями через OR: isDialogEnded = true, extractedPhone непустой, hasAnyContact = true. Хватает любого.
Если же ни одно условия не сработало — клиент просто общается с АИ агентом: ответ уходит в директ, агрегат сообщений чистится, сессия ждёт следующего сообщения. Диалог по типу «про каталог» или рассуждение по продукту без конкретной цели, не должен создавать задачи менеджеру.
Если сработало же условие = диалог уходит в CRM-ветку: обновление сделки (имя, телефон, дата рождения, сфера, дизайн, вердикт), постановка задачи менеджеру из тега [TASK:] и перенос по воронке из [STAGE:]. Разбор этой ветки — тема следующей части.
Отправка сообщения «как человек»
Есть ещё одна проблема, про которую редко пишут: даже идеально отформатированный ответ в мессенджере выглядит как чат-бот, если прилетает одним сообщением. Поэтому финальный этап у меня такой:
🔧 Code: Prepare IG Response режет готовый текст по предложениям, лимит — 140 символов на сообщение:```javascriptconst sentences = fullText.split(/(?<=[.!?])\s+/);const MAX_LENGTH = 140;const chunks = [];let currentChunk = "";for (const sentence of sentences) { // предложение не влезает в текущий чанк -> отдаём чанк, начинаем новый if ((currentChunk + sentence).length > MAX_LENGTH && currentChunk) { chunks.push({ json: { text: currentChunk.trim(), userId } }); currentChunk = sentence; } else { currentChunk += " " + sentence; }}if (currentChunk.trim()) chunks.push({ json: { text: currentChunk.trim(), userId } });return chunks;```
Дальше цикл: отправили часть — пауза — отправили следующую. Пауза между частями нужна, и серия сообщений читалась как живой набор текста, а не как выгрузка из шаблонной рассылки. Ничего не стоит, а диалог на стороне клиента выглядит как переписка с человеком.
Тут обычно спрашивают про таймауты: не держит ли пауза вебхук открытым? Нет: HTTP-ответ платформе уходит мгновенно ещё на входе в конвейер — контур приёма отдаёт 200 OK до того, как агент вообще начал генерировать ответ. Паузы между частями живут внутри уже закрывшегося выполнения, и Meta про них не знает ничего.
Параллельно уходит строка в Google Sheets: дата, время, chatId и вердикт из тега. Вся статистика из второй статьи построена именно на этих строках — вердикт из тегов оказался удобным ключом аналитики: не нужно перечитывать диалоги, чтобы понять, сколько диалогов было про цену, а сколько закончились анкетой.
Что в итоге в цифрах
Каждая сессия с клиентом пишет строку в Google Sheets: дата, время, chatId, вердикт — это первый лист.
Второй лист таблицы агрегирует эти данные обычными формулами. Ниже — данные за период, на момент публикации статьи:
5187 логов от 2585 уникальных клиентов (в среднем 2 сессии на человека). Сразу оговорка про математику, чтобы не искать ошибку: проценты считаются от уникальных клиентов, а не от логов. Клиент, который днём спросил цену, а ночью оставил данные, попадёт в обе категории — поэтому сумма процентов больше 100, а сумма клиентов по строкам таблицы (4511) превышает размер базы (2585).
Что здесь важного для бизнеса заказчика? Уверен, что основная боль, с которой сталкиваются наверняка многие кто внедряет проект, услышать от заказчика фразы и вопросы мол «Так а за что платить?! просто ответы по нашему продукту — это не завершение сделки ! Деньги не пришли — покупки же нет!«. Вот тут и включается математика и бизнес логика внедряемой автоматизации:
41% уникальных клиентов оставили данные для заказа. Это тот самый ORDER:DATA|HOT — главный триггер CRM-ветки: единственный вердикт, после которого в сделке лежит готовая анкета. 1061 карточка утром ждала менеджера. А уже КАК и ЧЕРЕЗ СКОЛЬКО времени клиент был обработан и доведён до покупки — зависит на прямую от них (менеджеров);
7% сложных вопросов и жалоб уехали менеджеру через SUPPORT:ESCALATE — это не сбои, а правильное направление беседы: АИ агент не выдумывает ответ там, где нужен человек! Тут сразу страхуемся от того момента, когда заказчик будет скидывать скрины и разгневанно писать «Твой тупой бот наговорил то чего у нас нету — что теперь ответить-клиент ждёт!»;
0.7% возражений «дорого» — довольно интересный момент: почти все разговоры о деньгах заканчиваются на этапе «сколько стоит» = далее уходят из диалога. Вопросами привлечения лидов автоматизация не занимается — это уже к SMM-щику, таргетологу и прочим суетологам);
Что дальше
В следующей статье разберём CRM-ветку целиком: как поля сделки заполняются из тегов и почему поле "Ответ ИИ" пишется в карточку отдельным полем; зачем задача менеджеру ставится с дедлайном, а не просто уведомлением; и стоп-кран — механизм, которым человек перехватывает диалог у бота, не ломая сессию.
Всем добра!) Буду рад вопросам и конструктивным комментариям!)
ссылка на оригинал статьи https://habr.com/ru/articles/1076198/