Всем привет! Напомню: в первой статье я показал дебаунс на Redis, который склеивает дробные сообщения клиента. Во второй — модуль привязки «чат = сделка» и то, почему вебхуки стоит держать в изолированных ключах. Если первые две части были про архитектуру и концепции, то эта — с обилием кода. Я покажу, как именно вырезаются теги, парсятся телефоны и нарезаются абзацы, потому что без кода это превратится в «я просто настроил ноды».
Обе предыдущие статьи заканчиваются примерно одинаково: «...и текст уходит ИИ‑агенту». Это, пожалуй, тот финал, на котором обычно ставят точку и заливают демо на 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 и готовит текст к отправке в мобильный мессенджер. В ней очистка — это набор скучных, но обязательных замен:
```javascript let 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 получит пустоту:
```javascript const 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 символов подобран эмпирически: на экране телефона это три‑пять строк, сообщение читается одним взглядом. Но тупой перенос по счётчику ломает списки, поэтому у списков, можно сказать, иммунитет:
```javascript const 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: имя, дату рождения, телефон, сферу, дизайн. Источников два: текст клиента и «эхо» от АИ агента — по сценарию захвата анкеты модель подтверждает принятые данные, и это подтверждение тоже можно парсить.
Имя клиента. Три паттерна и стоп‑лист:
```javascript const 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 видит в карточке реальное имя человека, а не «Здравствуйте».
Дата рождения. Регулярка на дд.мм.гггг, сначала ищем в сообщении клиента, потом в «эхе» АИ агента:
```javascript const 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) ``` **Сфера и дизайн.** Тут регулярки уже не нужны — работают словари стемов: ```javascript const 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 знаков, старый формат) -> 375XXXXXXXXX if (cleanPhone.startsWith('80') && cleanPhone.length === 11) cleanPhone = '375' + cleanPhone.substring(2); // 8XXXXXXXXXX -> 7XXXXXXXXXX else 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:
```javascript let debugSource = 'NONE'; // метка источника контакта: TOOL / PHONE_REGEX / SOCIAL_REGEX // senderId достаём из ключа памяти чата вида ig:<userId> if (senderId && parsedPhone === senderId) { cleanPhone = null; debugSource = 'SENDER_ID_BLOCKED'; // и видим это в логе выполнения }
Предохранитель № 2: не ловить собственные номера. В скриптах агента есть телефон ателье — он же в ответах на вопрос «как с вами связаться?». Сканируя объединённый текст «клиент + ответ агента», легко принять свой же контакт за номер телефона клиента:
```javascript const BLACKLIST = { phones: ['79001234567', '375291234567'], // в статье номера вымышленные telegram: ['@brand_bot', '@brand'] };
После обоих предохранителей в CRM попадает либо нормализованный телефон, либо @хэндл, либо честная пустота. От себя, могу сказать что честная пустота лучше выдуманного номера: менеджер видит, что контакта нет, и не звонит по несуществующему номеру.

Развилка: ответить в директ или тащить в CRM
Дальше простая IF нода с тремя условиями через OR: isDialogEnded = true, extractedPhone непустой, hasAnyContact = true. Хватает любого.

Если же ни одно условия не сработало — клиент просто общается с АИ агентом: ответ уходит в директ, агрегат сообщений чистится, сессия ждёт следующего сообщения. Диалог по типу «про каталог» или рассуждение по продукту без конкретной цели, не должен создавать задачи менеджеру.
Если сработало же условие = диалог уходит в CRM‑ветку: обновление сделки (имя, телефон, дата рождения, сфера, дизайн, вердикт), постановка задачи менеджеру из тега [TASK:] и перенос по воронке из [STAGE:]. Разбор этой ветки — тема следующей части.
Отправка сообщения «как человек»
Есть ещё одна проблема, про которую редко пишут: даже идеально отформатированный ответ в мессенджере выглядит как чат‑бот, если прилетает одним сообщением. Поэтому финальный этап у меня такой:
text ? Split AI Response -> ? Loop (SplitInBatches) -> ⏳ Typing Delay -> ? Send ``` Split режет готовый текст по предложениям, лимит — 140 символов на сообщение: ```javascript const 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‑ветку целиком: как поля сделки заполняются из тегов и почему поле "Ответ ИИ" пишется в карточку отдельным полем; зачем задача менеджеру ставится с дедлайном, а не просто уведомлением; и стоп‑кран — механизм, которым человек перехватывает диалог у бота, не ломая сессию.
Всем добра!) Буду рад вопросам и конструктивным комментариям!)