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::query()->delete(); Artisan::call('help:rebuild-knowledge'); }); function answerOf(string $question): string { Http::fake(['*' => Http::response([ 'result' => ['alternatives' => [['message' => ['text' => 'Ответ по фрагментам инструкции.']]]], ], 200)]); return app(BotAnswerService::class)->answer($question)->text; } it('здоровается и благодарит по-человечески, а не просит телефон', function () { // Прогон 12.07.2026: на «здравствуйте», «ало», «спасибо» бот отвечал // «в моей инструкции нет ответа, оставьте телефон» — отшивал на первой реплике. foreach (['здравствуйте', 'привет', 'добрый день', 'ало'] as $q) { $answer = app(BotAnswerService::class)->answer($q); expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT) ->and($answer->text)->not->toBe(BotAnswerService::ESCALATE_TEXT) ->and($answer->escalate)->toBeFalse() ->and($answer->text)->toContain('Здравствуйте'); } foreach (['спасибо', 'спасибо большое', 'благодарю'] as $q) { $answer = app(BotAnswerService::class)->answer($q); expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT) ->and($answer->escalate)->toBeFalse() ->and($answer->text)->toContain('Пожалуйста'); } }); it('на «ты бот или человек» отвечает честно и без телефона', function () { $answer = app(BotAnswerService::class)->answer('ты человек или робот?'); expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT) ->and($answer->escalate)->toBeFalse() ->and(mb_strtolower($answer->text))->toContain('бот'); }); it('«соедините с оператором» — эскалация, а не отказ', function () { // Прогон 12.07.2026: бот сухо отвечал «не могу соединить вас с оператором». Http::fake(); foreach ([ 'соедините с оператором', 'переключите на менеджера', 'свяжите меня с человеком', 'можно живого специалиста', ] as $q) { expect(app(BotAnswerService::class)->answer($q)->text)->toBe(BotAnswerService::ESCALATE_TEXT); } }); it('не зовёт специалиста там, где ответ есть в инструкции', function () { // Прогон 12.07.2026: слова «у меня», «менеджер», «мой проект» ложно уводили // на живого специалиста, хотя ответ есть в статьях. foreach ([ 'у меня кончился баланс что будет', 'можно дать доступ менеджеру только к сделкам', 'где мой проект пропал', ] as $q) { expect(answerOf($q))->not->toBe(BotAnswerService::ESCALATE_TEXT); } }); it('знает про бесплатный тест и подарок 1000 ₽ как про подарок, а не сумму пополнения', function () { $hits = app(KnowledgeSearch::class)->search('есть бесплатный тестовый период?', 3); expect($hits)->not->toBeEmpty() ->and($hits[0]->title)->toContain('Бесплатный тест'); $text = collect($hits)->pluck('content')->implode(' '); expect($text)->toContain('подарок') ->and($text)->toContain('уже лежит на вашем балансе'); }); it('знает, как работать с полученным номером: сначала СМС, через час звонок', function () { foreach (['что говорить когда я звоню по вашему лиду', 'есть скрипт продаж'] as $q) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect(collect($hits)->pluck('content')->implode(' '))->toContain('СМС'); } }); it('называет средние показатели, но не обещает результат', function () { $hits = app(KnowledgeSearch::class)->search('какая конверсия в продажу у ваших лидов', 3); expect($hits)->not->toBeEmpty() ->and($hits[0]->title)->toContain('Средние показатели'); // Статья режется на куски — смотрим её целиком. $article = KnowledgeChunk::query() ->where('source_path', 'help/srednie-pokazateli.md') ->orderBy('chunk_index') ->pluck('content') ->implode(' '); expect($article)->toContain('8–12') // из 100 номеров готовы разговаривать ->and($article)->toContain('5–7') // ещё столько же откликается на СМС ->and($article)->toContain('не обещание'); // но гарантий не даём }); it('знает про повторы номеров: один номер не вернётся 30 дней', function () { foreach (['дубликаты номеров бывают?', 'один и тот же человек может прийти дважды'] as $q) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect(collect($hits)->pluck('content')->implode(' '))->toContain('30 дней'); } }); /** * Правило владельца 13.07.2026: недозвон — обычная часть работы, замены за него НЕТ. * Раньше статья обещала «заменим» — бот повторял это клиентам, а компания не меняет. * Отвечаем мягко: совет по работе с номером + спорный случай разбирает специалист. */ it('на «не берёт трубку» даёт совет и путь к специалисту, а не обещание замены', function () { $hits = app(KnowledgeSearch::class)->search('а если номер недоступен или не берёт трубку', 3); expect($hits)->not->toBeEmpty(); $text = collect($hits)->pluck('content')->implode(' '); expect($text)->toContain('support@liderra.ru') ->and($text)->toContain('обычное дело') ->and($text)->not->toContain('заменим') ->and($text)->not->toContain('можем заменить'); }); it('знает про вывод остатка и отсутствие рассрочки', function () { foreach (['можно вывести остаток денег обратно', 'есть ли рассрочка или постоплата'] as $q) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect($hits[0]->title)->toContain('Вывод остатка'); } }); it('знает, что поддержка круглосуточная и телефона у неё нет', function () { $hits = app(KnowledgeSearch::class)->search('когда работает поддержка', 3); expect($hits)->not->toBeEmpty() ->and($hits[0]->title)->toContain('Поддержка'); $text = collect($hits)->pluck('content')->implode(' '); expect($text)->toContain('24/7') ->and($text)->toContain('Телефона поддержки у нас нет'); }); it('знает, как удалить аккаунт и свои данные', function () { foreach (['как удалить аккаунт', 'хочу удалить свои данные из вашей базы'] as $q) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect(collect($hits)->pluck('content')->implode(' '))->toContain('support@liderra.ru'); } }); it('не ограничивает ниши, но честно предупреждает про злоупотребление', function () { $hits = app(KnowledgeSearch::class)->search('а для микрозаймов подойдет?', 3); expect($hits)->not->toBeEmpty(); $text = collect($hits)->pluck('content')->implode(' '); expect($text)->toContain('Ограничений по нишам') ->and($text)->toContain('блокировк'); }); it('не врёт про деньги: минуса нет, списание без заявки невозможно', function () { $minus = app(KnowledgeSearch::class)->search('баланс ушел в минус, это как', 3); expect(collect($minus)->pluck('content')->implode(' '))->toContain('В минус аккаунт'); $charged = app(KnowledgeSearch::class)->search('списали деньги а заявки не пришли', 3); expect(collect($charged)->pluck('title')->implode(' | '))->toContain('Списания'); // Статья режется на куски — сам факт смотрим по всей статье. $article = KnowledgeChunk::query() ->where('source_path', 'help/spisaniya.md') ->orderBy('chunk_index') ->pluck('content') ->implode(' '); expect($article)->toContain('так тоже не бывает'); }); it('не врёт про выходные: заявки идут в отмеченные дни приёма', function () { $hits = app(KnowledgeSearch::class)->search('в выходные заявки идут?', 3); expect($hits)->not->toBeEmpty(); expect(collect($hits)->pluck('content')->implode(' '))->toContain('отмеченные дни'); }); it('сделку удалить нельзя — и статья это прямо говорит', function () { $hits = app(KnowledgeSearch::class)->search('можно ли удалить сделку', 3); expect($hits)->not->toBeEmpty(); expect(collect($hits)->pluck('content')->implode(' '))->toContain('Удалить сделку нельзя'); }); it('строгий поиск не перебивает более точное мягкое совпадение', function () { // Второй прогон 12.07.2026: «сколько стоит одна заявка» ушло в «не знаю». Строгий // поиск (все слова разом) нашёл «Создать проект» — там случайно есть все слова — // и не дал мягкому поиску показать «Тарифы», где эти слова стоят в заголовке. $cases = [ 'сколько стоит одна заявка' => 'Тарифы', 'когда придут деньги после оплаты по счету' => 'Оплата по счёту', ]; foreach ($cases as $q => $expected) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect($hits[0]->title)->toContain($expected); } }); it('отвечает на остальные вопросы второго прогона', function () { $cases = [ 'сколько заявок пришло за неделю' => 'Дашборд', 'почему так дорого' => 'Тарифы', 'вы это таргет или что' => 'О сервисе', 'что значит непроверенный номер' => 'Пометки', 'вы храните мои данные где, за границей?' => 'Безопасность', 'сайт тормозит' => 'Поддержка', ]; foreach ($cases as $q => $expected) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect($hits[0]->title)->toContain($expected); } }); it('закрывает последние пять провалов третьего прогона', function () { $cases = [ 'как включить 2fa' => 'Безопасность', 'работаете с ндс?' => 'Договор', 'что писать в поле ниша' => 'Собрать конкурентов', ]; foreach ($cases as $q => $expected) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect($hits[0]->title)->toContain($expected); } // «Сколько у вас клиентов» и «есть отзывы» — статья находилась, но ИИ считал // «мы не раскрываем клиентов» за отсутствие ответа. Ответ должен быть прямым. $hits = app(KnowledgeSearch::class)->search('сколько клиентов у вас уже есть', 3); $text = collect($hits)->pluck('content')->implode(' '); expect($text)->toContain('Сколько у вас клиентов') // вопрос назван прямо ->and($text)->toContain('не раскрываем'); // и на него дан прямой ответ }); it('закрывает два последних провала четвёртого прогона', function () { $cases = [ 'где посмотреть из какого проекта пришла заявка' => 'Сделки', 'а если лиды не окупятся' => 'Средние показатели', ]; foreach ($cases as $q => $expected) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect($hits[0]->title)->toContain($expected); } }); it('на непонятную реплику переспрашивает, а не просит телефон', function () { // Второй прогон: «не работает», «как» — бот сдавался и просил телефон. foreach (['не работает', 'как', 'ничего не понятно'] as $q) { $answer = app(BotAnswerService::class)->answer($q); expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT) ->and($answer->escalate)->toBeFalse() ->and($answer->text)->toContain('Уточните'); } }); it('короткий вопрос-продолжение ищет ответ с учётом прошлой реплики', function () { // Замечание владельца 12.07.2026: «у бота нет контекста?». Память у ИИ есть (6 реплик), // а ПОИСК смотрел только на текущую фразу: на «а сколько это стоит?» после разговора // про сбор конкурентов он приносил общие «Тарифы» — и ИИ отвечал не по тем бумагам. // Без контекста «а это платно?» не ведёт к статье про сбор источников. expect(app(KnowledgeSearch::class)->search('а это платно?', 3)) ->each(fn ($hit) => $hit->title->not->toContain('Собрать источники')); $history = [ ['role' => 'user', 'text' => 'как собрать источники конкурента'], ['role' => 'assistant', 'text' => 'В карточке конкурента — кнопка «Собрать источники».'], ]; Http::fake(['*' => Http::response([ 'result' => ['alternatives' => [['message' => ['text' => 'Ответ по фрагментам.']]]], ], 200)]); $answer = app(BotAnswerService::class)->answer('а это платно?', $history); $titles = KnowledgeChunk::query() ->whereIn('id', $answer->matchedChunkIds) ->pluck('title') ->implode(' | '); expect($titles)->toContain('Собрать источники конкурента'); }); it('не предлагает экскурсию, если статья подошла случайно — по слову из текста', function () { // Массовый прогон 12.07.2026: около двадцати ответов заканчивались ссылкой // «Показать на портале» на экскурсию не по теме — спросили про регистрацию, // а бот вёл на импорт данных. Экскурсию даём, только если статья реально по теме: // слово из вопроса попало в её заголовок или синонимы, а не просто в текст. config()->set('services.bot.tours_enabled', true); config()->set('app.url', 'https://liderra.ru'); KnowledgeChunk::query()->delete(); KnowledgeChunk::create([ 'source_path' => 'help/ch.md', 'title' => 'Списания', 'tour' => 'charges', 'topics' => 'списание, за что списали', 'chunk_index' => 0, 'content' => 'Списания можно выгрузить в Excel одной кнопкой.', ]); // Вопрос про Excel цепляет статью только текстом — экскурсии по списканиям тут не место. expect(answerOf('как выгрузить в excel'))->not->toContain('?tour='); // Вопрос про списания — по теме статьи: экскурсия уместна. expect(answerOf('за что списали деньги'))->toContain('?tour=charges'); }); it('не обещает готовой интеграции с амоCRM', function () { $hits = app(KnowledgeSearch::class)->search('интеграция с амосрм есть?', 3); expect($hits)->not->toBeEmpty(); expect(collect($hits)->pluck('content')->implode(' '))->toContain('Готовых интеграций'); });