Files
portal/app/tests/Feature/Bot/MassRunFixesTest.php
T
Дмитрий c5125e981e feat(bot): выбор модели + три замка безопасности + инструкция 51 статья
Прогнал 250 вопросов через настоящий вебхук шесть раз и сравнил четыре модели
вслепую силами судей-агентов. Итоги — в docs findings.

Модель. Появился переключатель services.jivo_bot.llm: yandex (данные не покидают
РФ) либо aitunnel (зарубежные модели через российский шлюз). По слепому сравнению
Claude Haiku 4.5 выиграл финал у Gemini 157:24 и у обеих моделей Яндекса: он
полнее, точнее по кнопкам и цифрам и говорит как человек, а не справочник.

Три замка, потому что живая модель = самоуверенная модель:
- PersonalDataMask — телефоны и почта вырезаются ДО отправки в любую модель
  (клиент оставляет номер в чате; за границу ПДн уходить не должны).
- ChatTextCleaner — markdown и смайлики (Jivo их не рисует): было 191 ответ
  с разметкой, стало 0.
- AnswerGuard — режет юридические гарантии («вас не оштрафуют», «мы гарантируем»,
  «не нарушаем закон») и ЛЮБУЮ цену, которой нет в выданных статьях. Пусто после
  чистки → зовём живого специалиста.

Поиск по инструкции переписан: выбрасываются вопросительные слова (из-за них
«какие у вас тарифы» находило выгрузку в Excel), ранжирование сперва по заголовку
и синонимам, выдача — три РАЗНЫЕ статьи и каждая целиком, плюс починка опечаток
(«каг сазадь праэкт») и учёт прошлой реплики в коротких «а это платно?».

Инструкция: 40 → 51 статья. Новые — по фактам владельца: бесплатный тест и
подарок 1000 ₽, «мы канал, а не реклама», СМС → через час звонок, средние
показатели (8–12 из 100 разговаривают, ещё 5–7 по СМС), номер не вернётся
30 дней, замена брака, вывод остатка, поддержка 24/7, удаление данных, ниши.
Вычищено враньё: минус баланса, «списали без заявки», пауза вечером, выходные,
«Вся РФ», статус «Отказ» (его нет — есть «Не реализовано»), amoCRM, платный
повторный сбор, нарастающий итог тарифных ступеней.

Тесты: 106 из 106. Каждая найденная тупость закреплена тестом.

НЕ НА ПРОДЕ. Выкат — только по прямой команде владельца.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:44:48 +03:00

346 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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('812') // из 100 номеров готовы разговаривать
->and($article)->toContain('57') // ещё столько же откликается на СМС
->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 дней');
}
});
it('знает, что делать, если номер недоступен — заменим по письму в поддержку', function () {
$hits = app(KnowledgeSearch::class)->search('а если номер недоступен или не берёт трубку', 3);
expect($hits)->not->toBeEmpty();
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('замен')
->and($text)->toContain('support@liderra.ru');
});
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.jivo_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('Готовых интеграций');
});