e969b7700a
Config: jivo_bot -> bot (убраны неиспользуемые webhook_secret/outbound_url/token - транспорт Jivo уже удалён), jivosite убран целиком (виджет больше не нужен). Env: JIVO_BOT_* -> BOT_*; JIVO_WIDGET_ID/VITE_JIVO_WIDGET_ID удалены. tours_enabled выключен по умолчанию (BOT_TOURS_ENABLED=false) — экскурсии «Показать на портале» уходили не по теме вопроса (живая проверка 13.07.2026), чинить релевантность отдельно; сторож-тест не даёт включить незаметно. Заодно убраны обнаруженные хвосты того же виджета, оставшиеся от прежних задач: JivoLivenessProbe (класс+тест+регистрация в мониторинге внешних сервисов), плитка «JivoSite» в админ-дашборде, встроенный скрипт виджета в welcome.blade.php, осиротевший тест resources/js/.../JivoWidget.vue (компонент уже был удалён ранее), declaration VITE_JIVO_WIDGET_ID. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
353 lines
19 KiB
PHP
353 lines
19 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\KnowledgeChunk;
|
||
use App\Services\Bot\BotAnswerService;
|
||
use App\Services\Bot\KnowledgeSearch;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Http;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
/**
|
||
* Массовый прогон 250 вопросов через настоящий вебхук (12.07.2026): 37 «не знаю»,
|
||
* 4 ложных эскалации, враньё про деньги и хамство на приветствии.
|
||
* Каждый тест — конкретная находка прогона.
|
||
*/
|
||
beforeEach(function () {
|
||
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::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('Готовых интеграций');
|
||
});
|