Files
portal/app/tests/Feature/Bot/DialogRunFixesTest.php
T
Дмитрий 8c3fa9a96b feat(bot): ответственность за источник — новая статья + сторож
Правило владельца 13.07.2026. Лидерра отдаёт ВСЕ номера, прошедшие через
выбранный клиентом источник (и входящие, и исходящие), и не сортирует людей
по темам. Источник конкурента может быть шире ниши клиента (клиника с 30
врачами против одной стоматологии); конкурент может расширить профиль,
ошибиться в рекламе или прозванивать купленную базу — это вне нашего контроля.

Заявка не по теме = не брак, замены за неё нет; но тон мягкий — объяснить
и развернуть клиента к работе с ИСТОЧНИКОМ (сузить, сменить, пауза).

Статья kachestvo-istochnika.md + мягкие напоминания в трёх местах, где речь
заходит об источниках. Сторож AnswerGuard::promisesNicheRelevance режет
обещания «только целевые / отсеиваем нецелевых / гарантируем трафик».
50 статей, тесты 153/153.

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

510 lines
30 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('Поддержка отвечает круглосуточно, пишите в любое время.');
});
/**
* Правило владельца 13.07.2026 (ответственность за источник). Лидерра отдаёт ВСЕ номера,
* прошедшие через выбранный источник, — и входящие, и исходящие. Что за люди идут через
* этот источник, решает не Лидерра: клиника конкурента может лечить 30 болезней, а наш
* клиент — только зубы; конкурент может расширить профиль, ошибиться в настройках рекламы
* или купить плохую базу для обзвона. Поэтому бот НЕ ИМЕЕТ ПРАВА обещать, что заявки будут
* строго по нише клиента, что мы «отсеиваем нецелевых» или «гарантируем целевой трафик» —
* этого мы обеспечить не можем, а за обещание придёт претензия.
*/
it('сторож режет обещание, что заявки будут строго по нише клиента', function () {
$guard = app(AnswerGuard::class);
$context = 'Мы передаём контакты людей, интересовавшихся темой по указанному вами источнику.';
$cases = [
'Все заявки будут строго по вашей нише.',
'Мы отсеиваем нецелевые заявки — придут только ваши клиенты.',
'Мы гарантируем целевой трафик по вашей теме.',
'Нерелевантные контакты к вам не попадут.',
];
foreach ($cases as $sentence) {
expect($guard->clean('Заявки приходят по указанному источнику. '.$sentence, $context))
->toBe('Заявки приходят по указанному источнику.');
}
});
/** Честное объяснение про источник — НЕ обещание, резать его нельзя. */
it('честное объяснение про широкий источник сторож не трогает', function () {
$guard = app(AnswerGuard::class);
$context = 'Заявки идут по выбранному источнику; если источник шире вашей ниши, придут и не ваши темы.';
$text = 'Если источник шире вашей темы, часть заявок будет не по вашей нише — источник выбираете вы.';
expect($guard->clean($text, $context))->toBe($text);
});