fix(bot): держим нить разговора и не теряем телефон клиента

Прогон 46 диалогами (250 реплик подряд в одном чате) вскрыл то, чего не видел
прогон отдельными вопросами:

- Клиент оставил телефон → бот не отвечал ВООБЩЕ: письмо в поддержку слалось
  синхронно внутри ответа, SMTP не отозвался → упала вся задача (tries=1).
  Теперь номер в чате = заявка на звонок: принимаем всегда (даже без просьбы
  бота), письмо уходит через очередь и не может уронить ответ.
- Бот «забывал» себя: сторож резал цену, взятую из истории чата, ответ пустел
  → эскалация. Числа из своих прошлых ответов теперь разрешены; память чата
  3 → 5 пар реплик; в эскалационную ветку тоже передаётся история.
- AnswerGuard: + телефон поддержки (его нет), + юр-гарантии в новых оборотах
  («Да, законно», «Штраф не прилетит»), + враньё «на паузе списаний нет»,
  + выдумки про 18:00/ночь, + «цена зависит от ниши»; снимает висящее «Но…»
  и голое «Правильно, не будут» после вырезанной лжи.
- Промпт: не повторять сказанное, не поддакивать клиенту, без нумерованных списков.
- pravilo-18-00.md: 18:00 — граница для ИЗМЕНЕНИЙ, а не конец приёма заявок.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-13 04:51:49 +03:00
parent 2d158670f0
commit b037a216ce
9 changed files with 402 additions and 18 deletions
+32 -7
View File
@@ -12,6 +12,7 @@ use App\Services\Bot\ContactCapture;
use App\Services\Bot\JivoBotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
@@ -40,8 +41,13 @@ class ProcessJivoMessageJob implements ShouldQueue
$this->onQueue('bot');
}
/** Сколько прошлых реплик чата отдаём боту как память разговора (3 пары вопрос-ответ). */
private const HISTORY_LIMIT = 6;
/**
* Сколько прошлых реплик чата отдаём боту как память разговора (5 пар вопрос-ответ).
* Было 3 пары и в живом диалоге бот «забывал» цифру, названную им же в начале:
* клиент спрашивал «минималка какая была, я забыл», а тот ответ уже выпал из окна
* (прогон диалогами 12.07.2026). Пять пар покрывают типичный разговор целиком.
*/
private const HISTORY_LIMIT = 10;
public function handle(): void
{
@@ -69,8 +75,11 @@ class ProcessJivoMessageJob implements ShouldQueue
'created_at' => now(),
]);
$answer = $this->awaitsContact()
? $this->captureContact()
// Номер в реплике = человек хочет, чтобы ему перезвонили. Принимаем ВСЕГДА, просил
// бот телефон или нет. Прогон диалогами 12.07.2026: клиент сам оставил номер, бот
// ответил «не могу работать с персональными данными в чате» — и контакт пропал.
$answer = ($this->awaitsContact() || app(ContactCapture::class)->extractPhone($this->text) !== null)
? $this->captureContact($history)
: app(BotAnswerService::class)->answer($this->text, $history);
app(JivoBotClient::class)->sendMessage($this->chatId, $this->clientId, $answer->text);
@@ -102,11 +111,16 @@ class ProcessJivoMessageJob implements ShouldQueue
* Клиент ответил на просьбу о телефоне. Номер есть шлём контакт письмом
* в поддержку; номера нет (передумал, задал новый вопрос) отвечаем как обычно.
*/
private function captureContact(): BotAnswer
/**
* @param list<array{role: string, text: string}> $history
*/
private function captureContact(array $history = []): BotAnswer
{
$phone = app(ContactCapture::class)->extractPhone($this->text);
if ($phone === null) {
return app(BotAnswerService::class)->answer($this->text);
// Клиент передумал и задал новый вопрос — отвечаем С ПАМЯТЬЮ разговора.
// Раньше история сюда не передавалась, и бот терял нить (прогон 12.07.2026).
return app(BotAnswerService::class)->answer($this->text, $history);
}
$rows = BotDialog::query()
@@ -129,7 +143,18 @@ class ProcessJivoMessageJob implements ShouldQueue
$to = (string) config('services.support.email');
if ($to !== '') {
Mail::to($to)->send(new BotContactRequestMail($phone, $question, $this->chatId, $transcript));
// Письмо — В ОЧЕРЕДЬ, не здесь. Прогон диалогами 12.07.2026: почта не ответила →
// упала вся задача (tries=1), и клиент, оставивший номер, не получил НИЧЕГО.
// Ответ клиенту важнее письма; очередь сама повторит отправку.
try {
Mail::to($to)->queue(new BotContactRequestMail($phone, $question, $this->chatId, $transcript));
} catch (\Throwable $e) {
// Номер уже в журнале bot_dialogs — не потеряется. Клиенту отвечаем как обычно.
Log::error('Не удалось поставить в очередь письмо с контактом', [
'chat_id' => $this->chatId,
'error' => $e->getMessage(),
]);
}
}
return new BotAnswer(BotAnswerService::CONTACT_TAKEN_TEXT, escalate: true);
+58 -1
View File
@@ -26,8 +26,46 @@ class AnswerGuard
'/не нарушаем|нарушени\w* нет|в полном соответствии/iu',
'/(всё|все)\s+(абсолютно\s+)?законно(?![\s,]*—)/iu',
'/риск\w*\s+минимал/iu',
// Прогон диалогами 12.07.2026: юр-гарантии пролезли в НОВЫХ формулировках —
// «Да, законно», «Штраф не прилетит за сам звонок», «Всё по закону, без бумаг».
'/^\s*(да|нет)[\s,—-]+законно/iu',
'/\b(это|всё|все|вс[ёе])\s+законно\b/iu',
'/штраф\w*\s+не\s+(прилет|грозит|светит)/iu',
'/не\s+(прилетит|грозит)\s+(?:\S+\s+){0,2}штраф/iu',
'/(всё|все)\s+по\s+(федеральному\s+)?закону/iu',
// Телефона поддержки НЕ СУЩЕСТВУЕТ (podderzhka.md: только чат и почта). Бот
// трижды за прогон отправил клиента «позвонить в поддержку» — искать нечего.
'/(позвон\w+|звон\w+|наберите|набрать)\s+(?:\S+\s+){0,3}(в\s+)?(поддержк|support|нам)/iu',
'/(телефон|номер)\s+(?:\S+\s+){0,2}поддержк/iu',
// 🔴 ВРАНЬЁ ПРО ДЕНЬГИ (повтор 12.07.2026, D08): «на паузе списаний нет». По
// pauza-proekta.md уже заказанные заявки дойдут и будут ОПЛАЧЕНЫ. Клиент строит
// на этом решение — цена ошибки его деньги.
// NB: контрольный прогон 12.07.2026 — то же враньё пришло ДРУГИМИ словами
// («на паузе заявки не приходят, значит, и платить не за что»), где слова
// «списание» нет вовсе. Ловим смысл, а не формулировку.
'/пауз\w*[^.!?]{0,70}(списан\w*\s+нет|не\s+(будет|будут)\s+списан|не\s+спиш|не\s+списыв|списан\w*\s+не\s+идут|деньги\s+не\s+спис|платить\s+не\s+за\s+что|не\s+платите)/iu',
'/(списан\w*\s+нет|не\s+(будет|будут)\s+списан|не\s+спиш|не\s+списыв|платить\s+не\s+за\s+что)[^.!?]{0,50}пауз/iu',
// «Деньги начнут списываться, только когда запустите обратно» — то же обещание.
'/(списыв|спиш)\w*[^.!?]{0,40}(только\s+)?когда[^.!?]{0,30}(запустите|возобнов|включите)/iu',
// Цена зависит ТОЛЬКО от ступени объёма (tarify.md). «Зависит от ниши и региона» —
// выдумка, на которой клиент строит расчёт (повтор 12.07.2026, D46).
'/цен\w*[^.!?]{0,40}зависит\s+от[^.!?]{0,30}(ниш|регион|источник|темат)/iu',
// 18:00 — граница вступления в силу ИЗМЕНЕНИЙ (pravilo-18-00.md), а не время,
// когда заявки перестают приходить (повтор 12.07.2026, D33).
'/заявк\w*[^.!?]{0,30}(перестают|прекращают|не)\s+приход\w*[^.!?]{0,20}(в\s+)?18/iu',
'/(после|с)\s+18[:.]?0?0?[^.!?]{0,30}заявк\w*\s+(не\s+)?(приход|идут|нет)/iu',
// «Ночью заявок не бывает», «поток идёт только днём» — выдумка: в статьях
// расписания приёма заявок нет (контрольный прогон 12.07.2026, D33).
'/ночью[^.!?]{0,30}заяв\w*[^.!?]{0,25}(не\s+бывает|не\s+приход|нет)/iu',
'/(поток|заявк\w*)[^.!?]{0,30}только\s+дн[её]м/iu',
];
/** Висящие союзы в начале — след от вырезанного предложения. */
private const DANGLING_START = '/^(но|а|однако|зато|при этом)\b[\s,]*/iu';
/** «Да», «Нет», «Правильно, не будут» — подтверждение без содержания. */
private const BARE_YES_NO = '/^\s*(да|нет|верно|правильно|именно так|совершенно верно|конечно)\b[^.!?]{0,25}[.!?]?\s*$/iu';
/**
* Чистит ответ модели.
*
@@ -40,14 +78,33 @@ class AnswerGuard
$allowed = $this->numbersIn($context);
$kept = [];
$cutSomething = false;
foreach ($sentences as $sentence) {
if ($this->isBanned($sentence) || $this->hasInventedPrice($sentence, $allowed)) {
$cutSomething = true;
continue;
}
$kept[] = $sentence;
}
return trim(implode(' ', $kept));
// Вырезали ложь, а перед ней осталось голое «Правильно, не будут» — обрубок,
// который врёт ровно так же (повтор 12.07.2026, D08). Такие «поддакивания»
// без содержания снимаем вместе с ложью.
if ($cutSomething) {
$kept = array_values(array_filter(
$kept,
fn (string $s) => preg_match(self::BARE_YES_NO, $s) !== 1
));
}
$text = trim(implode(' ', $kept));
// Вырезали первое предложение — следующее часто начинается с «Но…»/«А…», и клиент
// читает обрубок (повтор 12.07.2026). Снимаем висящий союз и возвращаем заглавную.
$text = (string) preg_replace(self::DANGLING_START, '', $text);
return $text === '' ? '' : mb_strtoupper(mb_substr($text, 0, 1)).mb_substr($text, 1);
}
private function isBanned(string $sentence): bool
+33 -3
View File
@@ -21,7 +21,12 @@ class BotAnswerService
// Деньги, жалобы, юристы — только живой специалист.
// NB: слово «договор» убрано из стоп-тем (владелец 12.07.2026): у нас публичная
// оферта, подписывать нечего — бот сам рассказывает про неё и закрывающие документы.
.'скидк|жалоб|претензи|юрист|верните деньги|вернуть деньги|возврат денег'
// NB: слова между глаголом и «деньгами» обязательны — «верните МНЕ деньги»
// проскакивало мимо стоп-темы (прогон 12.07.2026). Но «вернёмся к деньгам» —
// это НЕ возврат, а продолжение разговора: возвратные формы (-ся) исключены,
// иначе клиент, вернувшийся к теме оплаты, улетал к специалисту (повтор 12.07.2026).
.'скидк|жалоб|претензи|юрист'
.'|(верните|вернуть(?!ся)|верн[уи]\b|возврат\w*|возмест\w+)\s+(?:\S+\s+){0,3}(деньг|средств|оплат|платёж|платеж)'
// Кто наш поставщик данных — коммерческая тайна (в обе стороны).
.'|поставщик|(как(ой|ие)|кто|назов\w+|перечисл\w+|что за)\s+(?:\S+\s+){0,3}оператор'
// Эксклюзивность и перепродажа заявок — не обсуждаем ни в какую сторону.
@@ -175,7 +180,14 @@ class BotAnswerService
ДЛИНА: 25 предложений, не длиннее 500 знаков. Это чат, а не статья: клиент должен
прочитать ответ за десять секунд. Не перечисляй всё подряд дай самое нужное
и предложи спросить дальше. Никаких смайликов.
и предложи спросить дальше. Никаких смайликов и никаких нумерованных списков.
ПОМНИ РАЗГОВОР. Выше переписка с этим человеком. Отвечай на НОВУЮ реплику
и не пересказывай заново то, что уже говорил в этом чате: повтор одного и того же
абзаца раздражает. Если клиент переспрашивает («не понял», «поясните на пальцах»,
«а минималка какая была?») объясни ИНАЧЕ, проще, или коротко повтори только
нужную цифру. Если ты уже назвал факт выше он остаётся верным, не отказывайся
от него и не отправляй за ним к специалисту.
СТРОГИЕ ПРАВИЛА: отвечай ТОЛЬКО по приведённым ниже фрагментам инструкции;
если ответа в них нет скажи честно «в инструкции этого нет». Ничего не выдумывай.
Не обещай скидок, цен и сроков, которых нет в фрагментах. Не отвечай на вопросы
@@ -193,6 +205,9 @@ class BotAnswerService
её посмотреть, но не придумывай.
Не обещай «приглашу специалиста» и «переключу на оператора»: такого у нас нет.
Если вопрос не твой попроси номер телефона или дай почту support@liderra.ru.
ТЕЛЕФОНА ПОДДЕРЖКИ НЕ СУЩЕСТВУЕТ. Никогда не пиши «позвоните в поддержку»
и не называй никаких наших номеров: связь чат и почта support@liderra.ru.
Если клиент оставит свой номер специалист перезвонит ему сам.
Не давай гарантий: ни юридических («вас не оштрафуют», «всё законно», «работаем
в полном соответствии»), ни результата, ни «мы гарантируем, что…».
Не сочиняй речёвки и скрипты звонка от имени клиента или от имени Лидерры.
@@ -200,6 +215,11 @@ class BotAnswerService
«распределения заказа» и прочей механики, которой нет во фрагментах.
Без markdown: никаких звёздочек, решёток и обратных кавычек в чат уходит
обычный текст.
НЕ ПОДДАКИВАЙ. Клиент часто сам подсказывает ответ в вопросе («деньги
списываться не будут, пока пауза?»). Не соглашайся из вежливости: сверься
с фрагментами и, если клиент не прав, спокойно поправь. Живой прогон
12.07.2026: бот ответил «правильно, не будут», хотя за уже заказанные заявки
деньги списываются это враньё про деньги клиента.
ВАЖНО: если фрагменты прямо говорят, что чего-то НЕТ, что-то невозможно или что
мы чего-то НЕ РАСКРЫВАЕМ (например «СМС не приходят», «тип источника не меняется»,
@@ -222,7 +242,17 @@ class BotAnswerService
// Последний рубеж: вырезаем юридические гарантии и выдуманные цены. Модель бывает
// живой и убедительной — и ровно поэтому опасной (финальное сравнение 12.07.2026).
$text = $this->guard->clean($text, $context);
//
// Сторожу отдаём фрагменты ПЛЮС свои прошлые ответы в этом чате: их числа уже
// прошли эту же проверку. Иначе живой баг 12.07.2026 — клиент спросил «минималка
// какая была, я забыл», бот верно вспомнил 100 ₽ из своей же реплики, а сторож
// счёл цифру выдуманной, вычистил ответ до пустого и увёл человека к специалисту.
$said = implode(' ', array_map(
fn ($m) => (string) $m['text'],
array_filter($history, fn ($m) => ($m['role'] ?? '') === 'assistant')
));
$text = $this->guard->clean($text, $context."\n\n".$said);
if ($text === '') {
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
}
+3
View File
@@ -22,6 +22,9 @@ class ChatTextCleaner
$line = (string) preg_replace('/^\s*#{1,6}\s*/u', '', $line);
// Пункты списка «* », «- », «• » → тире.
$line = (string) preg_replace('/^\s*[*\-•]\s+/u', '— ', $line);
// Нумерованные пункты «1. », «2) » → тире: в чате это выглядит как инструкция
// из справки, а не как разговор (прогон диалогами 12.07.2026).
$line = (string) preg_replace('/^\s*\d{1,2}[.)]\s+/u', '— ', $line);
$lines[$i] = $line;
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Для каких ниш подходит и какие есть правила
tour: start
topics: для каких сфер, подойдёт ли мне, моя ниша, микрозаймы, мфо, медицина, юруслуги, запрещённые ниши, ограничения, кому нельзя, правила использования, злоупотребление, блокировка, бан, ответственность
topics: ставки на спорт, букмекер, казино, азартные игры, алкоголь, табак, возьмёте ли мою нишу, для каких сфер, подойдёт ли мне, моя ниша, микрозаймы, мфо, медицина, юруслуги, запрещённые ниши, ограничения, кому нельзя, правила использования, злоупотребление, блокировка, бан, ответственность
---
**Ограничений по нишам у нас нет.** Сервис не оценивает, чем занимается клиент, — он
+4
View File
@@ -8,6 +8,10 @@ topics: 18:00, вступает в силу, завтра, послезавтр
по проекту. Это одно и то же — заявки, которые приходят вам в «Сделки».
Когда они начнутся, когда прекратятся и почему не сразу — как раз про это статья.
**Важно: 18:00 — это НЕ время, когда заявки перестают приходить.** Это граница, после
которой ваши изменения (лимит, пауза, новый проект) начинают действовать на день позже.
Сами заявки приходят потоком в течение суток — «часа отключения» у них нет.
Заказ заявок на завтрашний день Лидерра фиксирует **вечером текущего дня**.
Поэтому у изменений есть граница — **18:00 по Москве**:
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: Создать проект — пошагово, все поля формы
tour: create-project
topics: создать проект, новый проект, форма проекта, тип источника, сайт, звонок, смс, смс проект, ключевое слово, отправитель, название проекта, реквизиты для первого проекта, сколько проектов можно, несколько сайтов в проекте, проект не запущен, формат номера телефона, в каком формате номер, формат сайта, в каком формате писать сайт, как писать домен, можно ли 8800, номер 8-800, регионы, вся россия, выбрать город, дни приёма, выходные, идут ли заявки в выходные, ночью
topics: создать проект, новый проект, форма проекта, тип источника, сайт, звонок, смс, смс проект, ключевое слово, отправитель, название проекта, реквизиты для первого проекта, сколько проектов можно, несколько сайтов в проекте, проект не запущен, формат номера телефона, в каком формате номер, формат сайта, в каком формате писать сайт, как писать домен, можно ли 8800, номер 8-800, регионы, вся россия, выбрать город, дни приёма, выходные, я не работаю в выходные, не работаю по субботам, только по будням, часы приёма заявок, с 10 до 19, идут ли заявки в выходные, ночью
---
Проект — это ваш заказ на поток заявок: вы говорите, **откуда** их брать
@@ -51,7 +51,9 @@ it('клиент прислал телефон после просьбы → п
(new ProcessJivoMessageJob('chat-c2', 'client-2', 'какой у меня баланс?'))->handle();
(new ProcessJivoMessageJob('chat-c2', 'client-2', 'мой номер +7 916 123-45-67'))->handle();
Mail::assertSent(BotContactRequestMail::class, function (BotContactRequestMail $mail) {
// Письмо уходит ЧЕРЕЗ ОЧЕРЕДЬ: почта не должна ронять ответ клиенту (прогон диалогами
// 12.07.2026 — SMTP не ответил, задача упала, клиент с номером остался без ответа).
Mail::assertQueued(BotContactRequestMail::class, function (BotContactRequestMail $mail) {
return $mail->hasTo('support@liderra.ru')
&& $mail->phone === '79161234567'
&& str_contains($mail->question, 'баланс');
@@ -61,12 +63,17 @@ it('клиент прислал телефон после просьбы → п
expect($last->message)->toContain('Спасибо');
});
it('телефон без предшествующей просьбы бота письмо не шлёт', function () {
it('телефон без просьбы бота — тоже принимаем: человек хочет, чтобы перезвонили', function () {
// Правило изменено 12.07.2026 по итогам прогона диалогами: раньше номер «без просьбы»
// игнорировался, и бот отвечал «не могу работать с персональными данными в чате».
// Номер в чате = заявка на звонок. Не теряем его никогда.
Mail::fake();
(new ProcessJivoMessageJob('chat-c3', 'client-3', 'звоните мне на +79161234567 если что'))->handle();
Mail::assertNothingSent();
Mail::assertQueued(BotContactRequestMail::class, fn ($m) => $m->phone === '79161234567');
$last = BotDialog::where('direction', 'out')->latest('id')->firstOrFail();
expect($last->message)->toContain('Спасибо');
});
it('номер распознаётся в разных форматах', function () {
@@ -79,6 +86,6 @@ it('номер распознаётся в разных форматах', funct
(new ProcessJivoMessageJob($chat, 'c', $phone))->handle();
}
Mail::assertSent(BotContactRequestMail::class, 3);
Mail::assertSent(BotContactRequestMail::class, fn ($m) => $m->phone === '79161234567');
Mail::assertQueued(BotContactRequestMail::class, 3);
Mail::assertQueued(BotContactRequestMail::class, fn ($m) => $m->phone === '79161234567');
});
@@ -0,0 +1,258 @@
<?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('Человек дал согласие оператору.');
});
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');
});