Files
portal/app/tests/Feature/Bot/DialogRunFixesTest.php
T
Дмитрий 004a0b1737 fix(bot): ювелирная доводка после второго круга судей
- «скидка ЗА ОБЪЁМ» больше не считается просьбой скидки: бот отвечает про
  ступени тарифа, а не уводит к специалисту.
- «где посмотреть мою ступень / мой лимит» — вопрос про портал, а не про
  личные данные клиента: эскалация снята.
- Цена ↔ ниша/регион: правило по СМЫСЛУ (AnswerGuard::priceTiedToNiche).
  Враньё «цена зависит от ниши» возвращалось ПЯТЬЮ формулировками —
  регулярка на каждую не работает. Честное «цена НЕ зависит от региона»
  сохраняется.
- Вырезано: «на тысячу выйдет от 2 до 20 заявок» (реально максимум 4),
  «работаем не со всеми нишами» / «есть направления, с которыми мы не
  сотрудничаем» (ограничений по нишам НЕТ), «не берёт трубку — забей»
  (такую заявку МЕНЯЮТ), «баланс на паузе не потратится», выдуманные сроки
  зачисления по счёту.
- Промпт: цена только от ступени объёма; запретных ниш нет.
- Статьи: тронута ТОЛЬКО строка topics (синонимы поиска), тексты не менялись.

Тесты 126/126. Бот НЕ на проде.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 05:35:11 +03:00

378 lines
22 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\ProcessJivoMessageJob;
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.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t');
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' => 'Пополнить баланс можно картой или по счёту.',
]);
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
});
/** Ответ модели, который получит бот. */
function llmSays(string $text): void
{
Http::fake([
'bot.jivosite.com/*' => Http::response(['ok' => true]),
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => $text]]]],
]),
]);
}
/**
* 🔴 Находка 1 (D28): клиент оставил телефон, почта не ответила — задача упала (tries=1),
* и клиент не получил НИЧЕГО. Ответ клиенту важнее письма.
*/
it('почта упала — клиент всё равно получил подтверждение, что номер принят', function () {
Mail::shouldReceive('to')->once()->andThrow(new RuntimeException('SMTP недоступен'));
(new ProcessJivoMessageJob('chat-mailfail', 'c1', 'верните мне деньги'))->handle();
(new ProcessJivoMessageJob('chat-mailfail', 'c1', '+7 999 123-45-67'))->handle();
$last = BotDialog::where('direction', 'out')->latest('id')->firstOrFail();
expect($last->message)->toBe(BotAnswerService::CONTACT_TAKEN_TEXT);
});
/**
* 🔴 Находка 2 (D29): клиент сам, без просьбы, написал номер — бот ответил «не могу работать
* с персональными данными в чате» и соврал «мы не перезваниваем». Номер в чате = человек
* хочет, чтобы перезвонили. Принимаем ВСЕГДА, просил бот телефон или нет.
*/
it('клиент оставил номер сам, без просьбы бота → контакт принят и ушёл письмом', function () {
Mail::fake();
llmSays('Пополнить баланс можно картой.');
(new ProcessJivoMessageJob('chat-selfphone', 'c2', 'как пополнить баланс'))->handle();
(new ProcessJivoMessageJob('chat-selfphone', 'c2', 'вот мой номер +7 900 000-11-22'))->handle();
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();
}
});
/**
* 🔴 Хвост прогона (D04): «не берёт трубку — значит, не заинтересован, гонять смысла нет».
* По zamena-zayavki.md такую заявку МЕНЯЮТ. Совет бота стоит клиенту денег.
*/
it('сторож режет совет «не берёт трубку — забей»', function () {
$guard = app(AnswerGuard::class);
$text = 'Если не берёт трубку — значит, не заинтересован, гонять смысла нет. Напишите в поддержку.';
expect($guard->clean($text, 'Заявку можно заменить.'))->toBe('Напишите в поддержку.');
});
/**
* 🔴 Хвост прогона (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');
});