Files
portal/app/tests/Feature/Bot/DialogRunFixesTest.php
T
Дмитрий 7f8b245b0c fix(bot): сторож режет любой выдуманный срок связи, а не список глаголов
Прогон через свой чат 13.07.2026: бот сказал «специалист перезванивает
в течение рабочего дня». Поддержка круглосуточная, «рабочего дня» у нас нет —
это обещание, которое можно нарушить. Прежнее правило перечисляло глаголы
(«ответят», «разберут») и пропустило «перезванивает».

Правило теперь по смыслу: связь + срок = режем; «круглосуточно» — оставляем.
Тесты 151/151.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:17:20 +03:00

475 lines
28 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\Jobs\Bot\ProcessChatMessageJob;
use App\Mail\BotContactRequestMail;
use App\Models\BotDialog;
use App\Models\KnowledgeChunk;
use App\Services\Bot\AnswerGuard;
use App\Services\Bot\BotAnswerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
/**
* Находки прогона ДИАЛОГАМИ (46 разговоров, 250 реплик, 12.07.2026). Прогон отдельными
* вопросами их не видел — они вылезают только в живой ленте чата, где бот обязан помнить,
* что уже сказал, и правильно принять оставленный телефон.
*/
beforeEach(function () {
config()->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');
});
/**
* Прогон через свой чат 13.07.2026: бот сказал «Обычно специалист перезванивает
* В ТЕЧЕНИЕ РАБОЧЕГО ДНЯ, но точное время я не знаю». Поддержка круглосуточная,
* никакого «рабочего дня» у нас нет — это выдуманное обещание, за которое придёт
* претензия. Прежнее правило ловило глаголы «ответят / разберут» и пропустило
* «перезванивает»: список глаголов проигрывает гонку формулировок. Правило —
* по СМЫСЛУ: любой обещанный СРОК СВЯЗИ режется, кроме честного «круглосуточно».
*/
it('сторож режет любой выдуманный срок связи, каким глаголом его ни назови', function () {
$guard = app(AnswerGuard::class);
$context = 'Поддержка работает круглосуточно, отвечает в чате и на почте support@liderra.ru.';
$cases = [
'Специалист перезванивает в течение рабочего дня.',
'Мы свяжемся с вами в течение суток.',
'Вам ответят за пару часов.',
'Специалист разберёт обращение в рабочие дни.',
];
foreach ($cases as $sentence) {
expect($guard->clean('Напишите на support@liderra.ru. '.$sentence, $context))
->toBe('Напишите на support@liderra.ru.');
}
});
/** Правда про круглосуточную поддержку — НЕ выдумка, резать её нельзя. */
it('честное «круглосуточно» сторож не трогает', function () {
$guard = app(AnswerGuard::class);
$context = 'Поддержка работает круглосуточно, 24/7.';
expect($guard->clean('Поддержка отвечает круглосуточно, пишите в любое время.', $context))
->toBe('Поддержка отвечает круглосуточно, пишите в любое время.');
});