set('services.support.email', 'support@liderra.ru'); config()->set('services.yandexgpt', [ 'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest', 'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion', 'timeout_seconds' => 8, ]); KnowledgeChunk::create([ 'source_path' => 'help/b.md', 'title' => 'Как пополнить баланс', 'tour' => null, 'topics' => 'пополнить, баланс, минималка', 'chunk_index' => 0, 'content' => 'Пополнить баланс можно картой или по счёту.', ]); }); /** Ответ модели, который получит бот. */ function llmSays(string $text): void { Http::fake([ 'llm.api.cloud.yandex.net/*' => Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => $text]]]], ]), ]); } /** * Мимикрирует то, что делает ChatController: пишет реплику клиента в журнал и * запускает джобу с её номером. */ function sendChat(string $chatId, string $text): void { $row = BotDialog::create([ 'chat_id' => $chatId, 'direction' => 'in', 'message' => $text, 'created_at' => now(), ]); (new ProcessChatMessageJob($chatId, (int) $row->id, $text))->handle(); } /** * 🔴 Находка 1 (D28): клиент оставил телефон, почта не ответила — задача упала (tries=1), * и клиент не получил НИЧЕГО. Ответ клиенту важнее письма. */ it('почта упала — клиент всё равно получил подтверждение, что номер принят', function () { Mail::shouldReceive('to')->once()->andThrow(new RuntimeException('SMTP недоступен')); sendChat('chat-mailfail', 'верните мне деньги'); sendChat('chat-mailfail', '+7 999 123-45-67'); $last = BotDialog::where('direction', 'out')->latest('id')->firstOrFail(); expect($last->message)->toBe(BotAnswerService::CONTACT_TAKEN_TEXT); }); /** * 🔴 Находка 2 (D29): клиент сам, без просьбы, написал номер — бот ответил «не могу работать * с персональными данными в чате» и соврал «мы не перезваниваем». Номер в чате = человек * хочет, чтобы перезвонили. Принимаем ВСЕГДА, просил бот телефон или нет. */ it('клиент оставил номер сам, без просьбы бота → контакт принят и ушёл письмом', function () { Mail::fake(); llmSays('Пополнить баланс можно картой.'); sendChat('chat-selfphone', 'как пополнить баланс'); sendChat('chat-selfphone', 'вот мой номер +7 900 000-11-22'); Mail::assertQueued(BotContactRequestMail::class, fn ($m) => $m->phone === '79000001122'); $last = BotDialog::where('direction', 'out')->latest('id')->firstOrFail(); expect($last->message)->toBe(BotAnswerService::CONTACT_TAKEN_TEXT); }); /** * 🔴 Находка 3 (D29): «верните мне деньги» проскочило мимо стоп-темы — слово «мне» между. * Деньги назад — всегда живой специалист. */ it('возврат денег с любыми словами внутри → эскалация', function () { Http::fake(); foreach ([ 'верните мне деньги за вчерашние заявки', 'хочу вернуть свои деньги', 'сделайте возврат средств', ] as $question) { $answer = app(BotAnswerService::class)->answer($question); expect($answer->escalate)->toBeTrue() ->and($answer->text)->toContain('живой специалист'); } }); /** * 🔴 Регрессия от находки 3: «так, вернёмся к деньгам. картой можно?» — это НЕ про возврат. * Первая версия паттерна уводила такого клиента к специалисту (повторный прогон 12.07.2026). */ it('«вернёмся к деньгам» — обычный вопрос, не возврат: без эскалации', function () { llmSays('Да, картой можно — в разделе «Биллинг».'); foreach (['так, вернемся к деньгам. картой можно?', 'вернёмся к оплате — счёт выставите?'] as $question) { $answer = app(BotAnswerService::class)->answer($question); expect($answer->escalate)->toBeFalse(); } }); /** * 🔴 Находка 4 (D19, D45, D11): бот выдумал ТЕЛЕФОН ПОДДЕРЖКИ — его не существует * (podderzhka.md: только почта и чат). Клиент будет искать номер, которого нет. */ it('сторож режет выдуманный телефон поддержки', function () { $guard = app(AnswerGuard::class); $text = 'Напишите на support@liderra.ru. Или позвоните в поддержку — там подскажут.'; expect($guard->clean($text, 'Поддержка отвечает в чате и на почте.')) ->toBe('Напишите на support@liderra.ru.'); }); /** * 🔴 Находка 5 (D20, D07): юридические гарантии проскакивают мимо сторожа в новых * формулировках: «Да, законно», «Штраф не прилетит», «Всё по закону». */ it('сторож режет юридические гарантии в любых формулировках', function () { $guard = app(AnswerGuard::class); $cases = [ 'Да, законно. Человек дал согласие.' => 'Человек дал согласие.', 'Штраф не прилетит за сам звонок. Звоните спокойно.' => 'Звоните спокойно.', 'Всё по закону, без бумаг и курьеров. Счёт в разделе Биллинг.' => 'Счёт в разделе Биллинг.', ]; foreach ($cases as $text => $expected) { expect($guard->clean($text, 'Заявки приходят по согласию абонента.'))->toBe($expected); } }); /** * 🔴 Находка 6 (D33): «минималка какая была, я забыл» → бот УШЁЛ К СПЕЦИАЛИСТУ, хотя сам * назвал 100 ₽ тремя репликами раньше. Причина: сторож режет цену, которой нет в найденных * статьях, — а бот взял её из истории чата. Числа из СВОИХ прошлых ответов уже проверены. */ it('цена из своего же прошлого ответа в этом чате не считается выдуманной', function () { $guard = app(AnswerGuard::class); $context = "### Как пополнить баланс\nПополнить баланс можно картой или по счёту."; $history = 'Минимальное пополнение — 100 ₽, максимум 1 000 000 ₽ за раз.'; expect($guard->clean('Минималка — 100 ₽.', $context.' '.$history)) ->toBe('Минималка — 100 ₽.'); }); /** * 🔴 Повторный прогон (D46): «цена заявки зависит от вашей ниши и региона» — выдумка. * Цена зависит ТОЛЬКО от ступени объёма (tarify.md). Клиенту это обещание про «свою цену» * особенно вредно: он строит на нём расчёт. */ it('сторож режет выдуманную зависимость цены от ниши и региона', function () { $guard = app(AnswerGuard::class); $text = 'Цена заявки зависит от вашей ниши и региона. Точную цифру видно в Биллинге.'; expect($guard->clean($text, 'Цена зависит от ступени объёма.')) ->toBe('Точную цифру видно в Биллинге.'); }); /** * 🔴 Повторный прогон (D33): «Заявки перестают приходить в 18:00, ночью их нет» — выдумка. * 18:00 — граница вступления в силу ИЗМЕНЕНИЙ (pravilo-18-00.md), а не конец приёма заявок. */ it('сторож режет выдумку «заявки перестают приходить в 18:00»', function () { $guard = app(AnswerGuard::class); $text = 'Заявки перестают приходить в 18:00. Изменения вступают в силу со следующего дня.'; expect($guard->clean($text, 'Изменения до 18:00 работают с завтрашнего дня.')) ->toBe('Изменения вступают в силу со следующего дня.'); }); /** * 🔴 Повторный прогон (D08): «на паузе списаний нет» — враньё ПРО ДЕНЬГИ. По pauza-proekta.md * уже заказанные заявки дойдут и будут оплачены: «поставил паузу вечером — спишется». */ it('сторож режет враньё «на паузе списаний нет»', function () { $guard = app(AnswerGuard::class); $cases = [ 'Правильно, не будут. На паузе заявки не приходят, поэтому и списаний нет.', 'Нет, при паузе списания не идут.', 'Пока проект на паузе, деньги не списываются.', ]; foreach ($cases as $text) { expect($guard->clean($text.' Пауза включается кнопкой в карточке проекта.', 'Пауза действует сразу.')) ->toBe('Пауза включается кнопкой в карточке проекта.'); } }); /** * 🔴 Контрольный прогон: то же враньё, но ДРУГИМИ СЛОВАМИ — сторож его не поймал. * Живые фразы из чата, слово «списание» в них вообще не звучит. */ it('сторож режет враньё про паузу в живых формулировках из чата', function () { $guard = app(AnswerGuard::class); $context = 'Пауза действует сразу, но заказанные заявки дойдут и будут оплачены.'; $out = $guard->clean( 'Правильно, не будут. На паузе заявки не приходят, значит, и платить не за что. ' .'Деньги начнут списываться только когда вы запустите проект обратно. ' .'Кнопка «Приостановить» — в карточке проекта.', $context ); expect($out)->toBe('Кнопка «Приостановить» — в карточке проекта.'); }); /** * 🔴 Контрольный прогон (D33): «ночью заявок не бывает», «поток идёт только днём» — * выдумка: в статьях такого нет. И висящее «Но» в начале ответа. */ it('сторож режет выдумку про «ночью заявок не бывает»', function () { $guard = app(AnswerGuard::class); $out = $guard->clean( 'Но я вижу, что поток идёт только днём, ночью заявок не бывает. ' .'Точную границу подскажут на support@liderra.ru.', 'Изменения до 18:00 работают с завтрашнего дня.' ); expect($out)->toBe('Точную границу подскажут на support@liderra.ru.'); }); /** * Косметика от сторожа: он вырезает первое предложение, и ответ начинается с «Но…» — * человек читает обрубок (повторный прогон 12.07.2026, D28/D32/D39/D43). */ it('после чистки ответ не начинается с висящего «Но»', function () { $guard = app(AnswerGuard::class); $text = 'Да, законно. Но человек дал согласие оператору.'; 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(); } }); /** * 🔴 Правило владельца 13.07.2026: заявку с недозвоном мы НЕ МЕНЯЕМ. Бот не имеет права * обещать замену или возврат денег — за такое обещание потом придёт злой клиент. * Но и грубить нельзя: недозвон — обычное дело, а не брак. */ it('сторож не даёт обещать замену заявки и возврат денег', function () { $guard = app(AnswerGuard::class); $context = 'Спорный случай разбирает специалист индивидуально.'; expect($guard->clean('Такую заявку мы заменим. Напишите на support@liderra.ru.', $context)) ->toBe('Напишите на support@liderra.ru.'); expect($guard->clean('Мы поменяем этот номер бесплатно. Укажите номер сделки.', $context)) ->toBe('Укажите номер сделки.'); expect($guard->clean('Вернём деньги за эту заявку. Укажите номер сделки.', $context)) ->toBe('Укажите номер сделки.'); }); /** * Правило владельца 13.07.2026: тема тонкая — сглаживать. Бот не обещает замену, * но и не рубит «замена не полагается, деньги не возвращаются»: остаётся совет * и путь к живому специалисту. */ it('сторож убирает жёсткий отказ «замена не полагается / деньги не возвращаются»', function () { $guard = app(AnswerGuard::class); $context = 'Спорный случай разбирает специалист индивидуально.'; expect($guard->clean('Замена за недозвон не полагается. Отправьте СМС и перезвоните позже.', $context)) ->toBe('Отправьте СМС и перезвоните позже.'); expect($guard->clean('Деньги не возвращаются. Напишите на support@liderra.ru.', $context)) ->toBe('Напишите на support@liderra.ru.'); }); /** * Поддержка работает 24/7 (podderzhka.md). «Ответят в течение рабочего дня» / «за пару * часов» — выдуманный срок, которого мы не обещали. */ it('сторож режет выдуманный срок ответа поддержки', function () { $guard = app(AnswerGuard::class); $text = 'Обычно специалист отвечает в течение рабочего дня. Напишите на support@liderra.ru.'; expect($guard->clean($text, 'Поддержка работает круглосуточно.')) ->toBe('Напишите на support@liderra.ru.'); }); it('сторож режет пренебрежительный совет «не берёт трубку — забей»', function () { $guard = app(AnswerGuard::class); $text = 'Если не берёт трубку — значит, не заинтересован, гонять смысла нет. Отправьте СМС.'; expect($guard->clean($text, 'Недозвон — обычное дело.'))->toBe('Отправьте СМС.'); }); it('сторож НЕ режет добрый совет про недозвон', function () { $guard = app(AnswerGuard::class); $text = 'Если человек не берёт трубку, не стоит опускать руки: отправьте СМС и перезвоните позже.'; expect($guard->clean($text, 'Недозвон — обычное дело, а не брак.'))->toBe($text); }); /** * 🔴 Хвост прогона (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 ₽.'); $history = [ ['role' => 'user', 'text' => 'как пополнить баланс'], ['role' => 'assistant', 'text' => 'Минимальное пополнение — 100 ₽, картой или по счёту.'], ]; $answer = app(BotAnswerService::class)->answer('а минималка какая была?', $history); expect($answer->escalate)->toBeFalse() ->and($answer->text)->toContain('100'); });