From 004a0b1737c82ee1ab78773762a2d4e825de144d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 13 Jul 2026 05:35:11 +0300 Subject: [PATCH] =?UTF-8?q?fix(bot):=20=D1=8E=D0=B2=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=BE=D0=B2=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=20=D0=BA=D1=80=D1=83=D0=B3=D0=B0=20?= =?UTF-8?q?=D1=81=D1=83=D0=B4=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «скидка ЗА ОБЪЁМ» больше не считается просьбой скидки: бот отвечает про ступени тарифа, а не уводит к специалисту. - «где посмотреть мою ступень / мой лимит» — вопрос про портал, а не про личные данные клиента: эскалация снята. - Цена ↔ ниша/регион: правило по СМЫСЛУ (AnswerGuard::priceTiedToNiche). Враньё «цена зависит от ниши» возвращалось ПЯТЬЮ формулировками — регулярка на каждую не работает. Честное «цена НЕ зависит от региона» сохраняется. - Вырезано: «на тысячу выйдет от 2 до 20 заявок» (реально максимум 4), «работаем не со всеми нишами» / «есть направления, с которыми мы не сотрудничаем» (ограничений по нишам НЕТ), «не берёт трубку — забей» (такую заявку МЕНЯЮТ), «баланс на паузе не потратится», выдуманные сроки зачисления по счёту. - Промпт: цена только от ступени объёма; запретных ниш нет. - Статьи: тронута ТОЛЬКО строка topics (синонимы поиска), тексты не менялись. Тесты 126/126. Бот НЕ на проде. Co-Authored-By: Claude Opus 4.8 --- app/app/Services/Bot/AnswerGuard.php | 47 +++++++- app/app/Services/Bot/BotAnswerService.php | 19 ++- app/resources/help/izuchenie-istochnikov.md | 2 +- app/resources/help/komanda-i-dostup.md | 2 +- app/resources/help/statusy-sdelok.md | 2 +- app/resources/help/tarify.md | 2 +- app/tests/Feature/Bot/DialogRunFixesTest.php | 119 +++++++++++++++++++ 7 files changed, 185 insertions(+), 8 deletions(-) diff --git a/app/app/Services/Bot/AnswerGuard.php b/app/app/Services/Bot/AnswerGuard.php index a7cb61a1..2c260550 100644 --- a/app/app/Services/Bot/AnswerGuard.php +++ b/app/app/Services/Bot/AnswerGuard.php @@ -49,7 +49,14 @@ class AnswerGuard '/(списыв|спиш)\w*[^.!?]{0,40}(только\s+)?когда[^.!?]{0,30}(запустите|возобнов|включите)/iu', // Цена зависит ТОЛЬКО от ступени объёма (tarify.md). «Зависит от ниши и региона» — // выдумка, на которой клиент строит расчёт (повтор 12.07.2026, D46). - '/цен\w*[^.!?]{0,40}зависит\s+от[^.!?]{0,30}(ниш|регион|источник|темат)/iu', + // Цена и ниша/регион не должны стоять рядом НИ В КАКОМ порядке: враньё приходило + // и как «цена зависит от ниши», и как «зависит от вашей ниши — цена разная». + // Связка «цена ↔ ниша/регион» вынесена в priceTiedToNiche(): она приходила пятью + // разными формулировками, гоняться за каждой регуляркой бессмысленно. + // «На тысячу выйдет от 2 до 20 заявок» — при минимальной ступени 250 ₽ это враньё + // (финал 12.07.2026, D46). Считать за клиента, сколько заявок выйдет, бот не должен. + '/на\s+(эту\s+)?(тысячу|1\s?000\s*₽?)[^.!?]{0,60}\d+[^.!?]{0,20}заяв/iu', + '/\d+\s*заяв\w*\s*(до|—|–|-)\s*\d+/iu', // 18:00 — граница вступления в силу ИЗМЕНЕНИЙ (pravilo-18-00.md), а не время, // когда заявки перестают приходить (повтор 12.07.2026, D33). '/заявк\w*[^.!?]{0,30}(перестают|прекращают|не)\s+приход\w*[^.!?]{0,20}(в\s+)?18/iu', @@ -58,6 +65,23 @@ class AnswerGuard // расписания приёма заявок нет (контрольный прогон 12.07.2026, D33). '/ночью[^.!?]{0,30}заяв\w*[^.!?]{0,25}(не\s+бывает|не\s+приход|нет)/iu', '/(поток|заявк\w*)[^.!?]{0,30}только\s+дн[её]м/iu', + // «Не берёт трубку — значит, не заинтересован, гонять смысла нет»: по + // zamena-zayavki.md такую заявку МЕНЯЮТ. Совет бота стоит клиенту денег (D04). + '/(не\s+бер[её]т\s+трубку|не\s+отвечает|автоответчик)[^.!?]{0,60}(не\s+заинтересован|смысла\s+нет|не\s+стоит|заб\w+|бесполезн)/iu', + '/(не\s+заинтересован|смысла\s+нет\s+звонить)[^.!?]{0,40}(не\s+бер[её]т|не\s+отвеча)/iu', + // «Работаем не со всеми нишами / есть ограничения по нишам» — прямое противоречие + // nishi-i-pravila.md («Ограничений по нишам у нас нет») (D22). + // NB: враньё про ниши приходило ЧЕТЫРЬМЯ разными формулировками — последняя вообще + // без слова «ниши» рядом («работаем с разными нишами, но не со всеми»). Ловим смысл. + '/(не\s+со\s+всеми\s+ниш|ограничени\w*\s+по\s+ниш|есть\s+ограничения[^.!?]{0,25}ниш|работаем\s+не\s+с\s+кажд|не\s+со\s+всеми\s+подряд)/iu', + '/(ниш|сфер)\w*[^.!?]{0,40}(но\s+)?не\s+со\s+всеми/iu', + '/(есть\s+)?направлени\w*[^.!?]{0,40}не\s+(сотруднича|работа)\w*/iu', + '/не\s+(сотруднича|работа)\w*[^.!?]{0,30}(вообще|с\s+некотор|с\s+рядом|с\s+определ)/iu', + // Пауза, третья формулировка: «баланс при этом не потратится / не тронется» + // (финал 12.07.2026, D08). Уже заказанные заявки будут оплачены. + '/баланс[^.!?]{0,30}(не\s+потрат|не\s+тронет|не\s+уйд[её]т|останется\s+нетронут)/iu', + // Сроки зачисления по счёту («обычно 1–3 рабочих дня») — в статьях срока НЕТ. + '/(зачислен\w*|зачисл\w+|поступ\w+|деньги\s+прид\w+)[^.!?]{0,40}\d[^.!?]{0,15}(рабочих\s+дн|дн[ея])/iu', ]; /** Висящие союзы в начале — след от вырезанного предложения. */ @@ -80,7 +104,9 @@ class AnswerGuard $kept = []; $cutSomething = false; foreach ($sentences as $sentence) { - if ($this->isBanned($sentence) || $this->hasInventedPrice($sentence, $allowed)) { + if ($this->isBanned($sentence) + || $this->priceTiedToNiche($sentence) + || $this->hasInventedPrice($sentence, $allowed)) { $cutSomething = true; continue; @@ -107,6 +133,23 @@ class AnswerGuard return $text === '' ? '' : mb_strtoupper(mb_substr($text, 0, 1)).mb_substr($text, 1); } + /** + * Цена привязана к нише/региону/источнику — такой зависимости НЕТ (tarify.md: цена + * зависит только от ступени объёма). Враньё приходило пятью формулировками подряд + * («цена зависит от ниши», «зависит от вашей ниши — цена разная», «цена заявки + * в вашей нише»), поэтому правило по смыслу: цена и ниша не стоят рядом. + * + * Исключение — честное отрицание («цена НЕ зависит от региона»): его резать нельзя. + */ + private function priceTiedToNiche(string $sentence): bool + { + $hasPrice = preg_match('/(цен\w*|стоимост\w*|дешевле|дороже)/iu', $sentence) === 1; + $hasNiche = preg_match('/(ниш\w*|регион\w*|категори\w*|тематик\w*|город\w*|направлени\w*|сфер\w*)/iu', $sentence) === 1; + $isDenial = preg_match('/(не\s+зависит|одинаков\w*|независимо\s+от|не\s+связан\w*|не\s+ограничива\w*|не\s+разн\w*)/iu', $sentence) === 1; + + return $hasPrice && $hasNiche && ! $isDenial; + } + private function isBanned(string $sentence): bool { foreach (self::BANNED as $pattern) { diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index 085ea8a7..f276702b 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -25,7 +25,9 @@ class BotAnswerService // проскакивало мимо стоп-темы (прогон 12.07.2026). Но «вернёмся к деньгам» — // это НЕ возврат, а продолжение разговора: возвратные формы (-ся) исключены, // иначе клиент, вернувшийся к теме оплаты, улетал к специалисту (повтор 12.07.2026). - .'скидк|жалоб|претензи|юрист' + // «Скидка ЗА ОБЪЁМ» — это про ступени тарифа (документировано), а не просьба скидки: + // стоп-тема на неё уводила клиента к специалисту зря (финал 12.07.2026, D05). + .'скидк(?!\w*\s+(за|от)\s+объ[ёе]м)|жалоб|претензи|юрист' .'|(верните|вернуть(?!ся)|верн[уи]\b|возврат\w*|возмест\w+)\s+(?:\S+\s+){0,3}(деньг|средств|оплат|платёж|платеж)' // Кто наш поставщик данных — коммерческая тайна (в обе стороны). .'|поставщик|(как(ой|ие)|кто|назов\w+|перечисл\w+|что за)\s+(?:\S+\s+){0,3}оператор' @@ -88,7 +90,14 @@ class BotAnswerService * «Как это делается» — вопрос о функции портала, а не о состоянии счёта клиента. * Снимает ложную эскалацию с «перенести МОИ заявки из амоцрм» (живой урок 12.07.2026). */ - private const HOWTO_PATTERN = '/(перенест|перенос|импорт|загрузи|выгрузи|экспорт|скача|как\s+(создать|поменять|сменить|настроить|изменить|добавить|удалить))/iu'; + private const HOWTO_PATTERN = '/(перенест|перенос|импорт|загрузи|выгрузи|экспорт|скача' + .'|как\s+(создать|поменять|сменить|настроить|изменить|добавить|удалить)' + // «Где посмотреть мою ступень / мой лимит» — вопрос ПРО ПОРТАЛ, а не про личные + // данные: показать надо раздел, а не цифру. Бот уводил такое к специалисту, хотя + // сам же называл раздел парой реплик выше (прогон диалогами 12.07.2026, D05). + .'|где\s+(посмотреть|смотреть|найти|увидеть|видно|искать)' + .'|в\s+каком\s+раздел' + .')/iu'; /** * Эскалация (правило владельца 12.07.2026): бот не переводит чат на оператора — @@ -215,6 +224,12 @@ class BotAnswerService «распределения заказа» и прочей механики, которой нет во фрагментах. — Без markdown: никаких звёздочек, решёток и обратных кавычек — в чат уходит обычный текст. + — ЦЕНА ЗАВИСИТ ТОЛЬКО ОТ СТУПЕНИ ОБЪЁМА. Никогда не связывай её с нишей, регионом, + городом, источником или «категорией» — такой зависимости нет. И не считай за + клиента, сколько заявок выйдет на его сумму («на тысячу — от 2 до 20») — назови + цену ступени и предложи посмотреть сетку в Биллинге. + — НИКАКИХ ЗАПРЕТНЫХ НИШ. Ограничений по нишам нет: не выдумывай «направления, + с которыми мы не работаем», и не говори «работаем не со всеми». — НЕ ПОДДАКИВАЙ. Клиент часто сам подсказывает ответ в вопросе («деньги списываться не будут, пока пауза?»). Не соглашайся из вежливости: сверься с фрагментами и, если клиент не прав, спокойно поправь. Живой прогон diff --git a/app/resources/help/izuchenie-istochnikov.md b/app/resources/help/izuchenie-istochnikov.md index 48cc53a5..6652f86f 100644 --- a/app/resources/help/izuchenie-istochnikov.md +++ b/app/resources/help/izuchenie-istochnikov.md @@ -1,7 +1,7 @@ --- title: Собрать источники конкурента (шаг 2) — цена, очередь, повторный сбор tour: study-sources -topics: источников не нашлось деньги спишутся, ничего не нашлось спишут ли деньги, пустой результат сбора, висит в очереди, зависло, долго идёт, не двигается, собрать источники, изучить конкурента, шаг 2, 50 рублей, цена изучения, повторный сбор, собрать источники ещё раз, пакетный сбор, очередь, вы в очереди, не хватает баланса на сбор, источники в работе +topics: чем изучение отличается от подбора, разница между подбором и изучением, чем отличается сбор от подбора, источников не нашлось деньги спишутся, ничего не нашлось спишут ли деньги, пустой результат сбора, висит в очереди, зависло, долго идёт, не двигается, собрать источники, изучить конкурента, шаг 2, 50 рублей, цена изучения, повторный сбор, собрать источники ещё раз, пакетный сбор, очередь, вы в очереди, не хватает баланса на сбор, источники в работе --- Чтобы получить заявки, мало знать конкурента — нужны его **источники**: сайты diff --git a/app/resources/help/komanda-i-dostup.md b/app/resources/help/komanda-i-dostup.md index b2e9c77d..90cc425b 100644 --- a/app/resources/help/komanda-i-dostup.md +++ b/app/resources/help/komanda-i-dostup.md @@ -1,7 +1,7 @@ --- title: Могут ли работать несколько сотрудников в одном кабинете tour: security -topics: несколько человек, два человека, три сотрудника, команда, сотрудники, коллеги, вместе работать, одновременно, второй пользователь, роли, доступ сотруднику, менеджеры, разделить доступ, один аккаунт +topics: несколько человек, нас трое, нас двое, всем сотрудникам зайти, один логин на всех, два человека, три сотрудника, команда, сотрудники, коллеги, вместе работать, одновременно, второй пользователь, роли, доступ сотруднику, менеджеры, разделить доступ, один аккаунт --- Сейчас в кабинет входят **по одной учётной записи** — отдельных сотрудников с личными diff --git a/app/resources/help/statusy-sdelok.md b/app/resources/help/statusy-sdelok.md index 1ef4d9d4..107dfd77 100644 --- a/app/resources/help/statusy-sdelok.md +++ b/app/resources/help/statusy-sdelok.md @@ -1,7 +1,7 @@ --- title: Статусы сделок — все пять и как менять tour: deal-status -topics: статусы, воронка, новая сделка, просмотрено, в работе, сделка, не реализовано, поменять статус, этапы, сменить статус массово, что значит статус +topics: статусы, воронка, какие статусы есть, перечислите статусы, список статусов, что за статусы, новая сделка, просмотрено, в работе, сделка, не реализовано, поменять статус, этапы, сменить статус массово, что значит статус --- В воронке ровно **пять статусов**: diff --git a/app/resources/help/tarify.md b/app/resources/help/tarify.md index 7c58e1fb..fde066c8 100644 --- a/app/resources/help/tarify.md +++ b/app/resources/help/tarify.md @@ -1,7 +1,7 @@ --- title: Тарифы — сколько стоит заявка tour: tariffs -topics: тариф, тарифы, какие тарифы, цена, сколько стоит заявка, стоимость одной заявки, цена за лид, почему так дорого, дорого, высокая цена, ступени, 7 ступеней, скидка за объём, чем больше тем дешевле, абонплата, абонентская плата, есть ли абонентская плата, ежемесячная плата, подписка, комиссия, вы здесь, цена в следующем месяце +topics: тариф, тарифы, где посмотреть ступень, какая у меня ступень, скидка за объём, скидка за объем, дешевеет ли, какие тарифы, цена, сколько стоит заявка, стоимость одной заявки, цена за лид, почему так дорого, дорого, высокая цена, ступени, 7 ступеней, скидка за объём, чем больше тем дешевле, абонплата, абонентская плата, есть ли абонентская плата, ежемесячная плата, подписка, комиссия, вы здесь, цена в следующем месяце --- Абонентской платы нет. Вы платите **только за фактически полученные заявки** — diff --git a/app/tests/Feature/Bot/DialogRunFixesTest.php b/app/tests/Feature/Bot/DialogRunFixesTest.php index 2345a09b..09483981 100644 --- a/app/tests/Feature/Bot/DialogRunFixesTest.php +++ b/app/tests/Feature/Bot/DialogRunFixesTest.php @@ -243,6 +243,125 @@ it('после чистки ответ не начинается с висяще expect($guard->clean($text, 'Согласие абонента.'))->toBe('Человек дал согласие оператору.'); }); +/** + * 🔴 Хвост прогона (D05): «где посмотреть, какая у меня сейчас ступень» — это вопрос + * ПРО ПОРТАЛ («где смотреть»), а не про личные данные клиента. Бот уводил к специалисту, + * хотя сам же двумя репликами выше называл раздел. + */ +it('«где посмотреть...» — это про портал, а не про личные данные: без эскалации', function () { + llmSays('Ступень видна в разделе «Биллинг» на панели «Цены за лид».'); + + KnowledgeChunk::create([ + 'source_path' => 'help/t.md', 'title' => 'Тарифы', 'tour' => null, + 'topics' => 'ступень, лимит, списания, цена за лид', 'chunk_index' => 0, + 'content' => 'Ступень, лимит и списания видно в разделе «Биллинг».', + ]); + + foreach ([ + 'где посмотреть какая у меня сейчас ступень', + 'где найти мой лимит заявок', + 'где увидеть мои списания', + ] as $question) { + $answer = app(BotAnswerService::class)->answer($question); + expect($answer->escalate)->toBeFalse(); + } +}); + +/** + * 🔴 Хвост прогона (D04): «не берёт трубку — значит, не заинтересован, гонять смысла нет». + * По zamena-zayavki.md такую заявку МЕНЯЮТ. Совет бота стоит клиенту денег. + */ +it('сторож режет совет «не берёт трубку — забей»', function () { + $guard = app(AnswerGuard::class); + + $text = 'Если не берёт трубку — значит, не заинтересован, гонять смысла нет. Напишите в поддержку.'; + expect($guard->clean($text, 'Заявку можно заменить.'))->toBe('Напишите в поддержку.'); +}); + +/** + * 🔴 Хвост прогона (D22): «работаем не со всеми нишами, есть ограничения» — прямое + * противоречие nishi-i-pravila.md («Ограничений по нишам у нас нет»). + */ +it('сторож режет выдумку про ограничения по нишам', function () { + $guard = app(AnswerGuard::class); + + $text = 'Мы работаем не со всеми нишами, есть ограничения. Проверить просто: если у конкурента есть сайт.'; + expect($guard->clean($text, 'Ограничений по нишам у нас нет.')) + ->toBe('Проверить просто: если у конкурента есть сайт.'); +}); + +/** + * 🔴 Финал (D05): «скидка за объём есть?» — это про ступени тарифа (tarify.md), а не просьба + * скидки. Стоп-слово «скидк» уводило клиента к специалисту, а бот следующей же репликой + * сам всё объяснял. + */ +it('«скидка за объём» — это про тарифы, а не просьба скидки: без эскалации', function () { + llmSays('Чем больше заявок за месяц, тем дешевле следующие — всего 7 ступеней.'); + + KnowledgeChunk::create([ + 'source_path' => 'help/t2.md', 'title' => 'Тарифы', 'tour' => null, + 'topics' => 'скидка за объём, ступени, тариф', 'chunk_index' => 0, + 'content' => 'Цена зависит от объёма: 7 ступеней нарастающим итогом.', + ]); + + foreach (['а скидка за объем есть?', 'есть скидка за объём?'] as $question) { + expect(app(BotAnswerService::class)->answer($question)->escalate)->toBeFalse(); + } + + // Просто «дайте скидку» — по-прежнему стоп-тема. + expect(app(BotAnswerService::class)->answer('дайте скидку')->escalate)->toBeTrue(); +}); + +/** + * 🔴 Финал: враньё про паузу и ниши пришло ТРЕТЬИМИ формулировками. Ловим смысл. + */ +it('сторож режет «баланс не потратится на паузе» и «работаем не со всеми нишами»', function () { + $guard = app(AnswerGuard::class); + + expect($guard->clean( + 'Баланс при этом не потратится. Пауза включается кнопкой.', + 'Пауза действует сразу, заказанные заявки будут оплачены.' + ))->toBe('Пауза включается кнопкой.'); + + expect($guard->clean( + 'Мы работаем с разными нишами, но не со всеми подряд. Проверить просто.', + 'Ограничений по нишам у нас нет.' + ))->toBe('Проверить просто.'); + + // Четвёртая формулировка того же вранья — слова «ниши» рядом с «не со всеми» уже нет. + expect($guard->clean( + 'Мы работаем с разными нишами, но не со всеми. Микрозаймы берём.', + 'Ограничений по нишам у нас нет.' + ))->toBe('Микрозаймы берём.'); +}); + +/** + * 🔴 Финал (D46, D32): бот снова привязал цену к нише/региону/источнику и пообещал + * «на 1000 ₽ выйдет от 2 до 20 заявок» (при минимальной ступени 250 ₽ — максимум 4). + * Клиент строит на этом расчёт, поэтому режем. + */ +it('сторож режет цену «от ниши/региона» и выдуманное «сколько заявок на тысячу»', function () { + $guard = app(AnswerGuard::class); + $context = 'Цена зависит от объёма: 7 ступеней.'; + + expect($guard->clean('Стоимость заявки зависит от вашей ниши и региона. Сетку видно в Биллинге.', $context)) + ->toBe('Сетку видно в Биллинге.'); + + expect($guard->clean('На тысячу выйдет от 2 до 20 заявок. Точную цену видно в Биллинге.', $context)) + ->toBe('Точную цену видно в Биллинге.'); + + expect($guard->clean('Зависит от вашей ниши и региона — цена за заявку разная. Смотрите Биллинг.', $context)) + ->toBe('Смотрите Биллинг.'); + + // Пятая формулировка того же вранья. + expect($guard->clean('Зависит от цены заявки в вашей нише — она разная. Смотрите Биллинг.', $context)) + ->toBe('Смотрите Биллинг.'); + + // ПРАВДУ резать нельзя: «цена НЕ зависит от региона» — верный ответ, он обязан выжить. + expect($guard->clean('Цена не зависит от региона и ниши. Она зависит только от объёма.', $context)) + ->toBe('Цена не зависит от региона и ниши. Она зависит только от объёма.'); +}); + it('короткое «а минималка какая была?» после ответа бота не уводит к специалисту', function () { llmSays('Минимальное пополнение — 100 ₽.');