2026-07-14 15:32:15 +03:00
< ? php
declare ( strict_types = 1 );
use App\Jobs\Bot\ProcessChatMessageJob ;
use App\Models\BotDialog ;
use App\Models\KnowledgeChunk ;
use App\Services\Bot\LivePrices ;
use Illuminate\Foundation\Testing\RefreshDatabase ;
use Illuminate\Support\Facades\DB ;
use Illuminate\Support\Facades\Http ;
uses ( RefreshDatabase :: class );
/**
* Живой разговор на БОЕВОМ 14.07.2026. Гость спросил цену — бот назвал «500 ₽ первая
* заявка месяца» (такого тарифа нет вообще, сетка 55→25 ₽), клиент поймал его на вранье
* и ушёл: «пока я с вами работать не буду».
*
* Три поломки из одного разговора:
* 1. Цены. В pricing_tiers лежат СТАРЫЕ сетки (версии по effective_from). Портал берёт
* свежую через PricingTierRepository, а бот читал таблицу напрямую и склеивал ВСЕ
* версии в один список: «ступень 1 — 500 ₽; ступень 1 — 70 ₽; ступень 1 — 55 ₽…».
* Сторож вранья это пропустил — и правильно: 500 ₽ и правда лежало в поданных статьях.
* 2. Ссылка на разбор мимо темы: у статьи про сбор источников в ЗАГОЛОВКЕ есть слово
* «цена» — и она перебивала «Тарифы» на любом вопросе про деньги.
* 3. Ответ начинался с середины фразы («Но если вы хотите…») — клиент решил, что ему
* хамят. Висящий союз снимался ДО того, как из ответа вырезалась отговорка
* «про это в инструкции нет», а не после.
*/
beforeEach ( function () {
config () -> set ( 'services.support.email' , 'support@liderra.ru' );
config () -> set ( 'services.bot.tours_enabled' , true );
config () -> set ( 'services.bot.landing_url' , 'https://liderra.ru' );
config () -> set ( 'guides.slugs' , [ 'tarify' , 'izuchenie-istochnikov' ]);
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 ,
]);
});
/** Ответ модели, который получит бот. */
function llmAnswers ( string $text ) : void
{
Http :: fake ([
'llm.api.cloud.yandex.net/*' => Http :: response ([
'result' => [ 'alternatives' => [[ 'message' => [ 'role' => 'assistant' , 'text' => $text ]]]],
]),
]);
}
/** Гость пишет в чат (как это делает ChatController). */
function askBot ( string $chatId , string $text ) : void
{
$row = BotDialog :: create ([
'chat_id' => $chatId ,
'direction' => 'in' ,
'message' => $text ,
'source' => 'landing' , // гость с лендинга: ему полагается разбор, а не экскурсия
'created_at' => now (),
]);
( new ProcessChatMessageJob ( $chatId , ( int ) $row -> id , $text )) -> handle ();
}
/** Две сетки тарифов: старая (её портал уже не применяет) и действующая. */
function twoPricingGrids () : void
{
DB :: table ( 'pricing_tiers' ) -> delete ();
// Старая сетка — та самая, из которой бот брал «500 ₽».
foreach ([[ 1 , 100 , 50000 ], [ 2 , 200 , 45000 ], [ 3 , 400 , 40000 ]] as [ $no , $leads , $kop ]) {
DB :: table ( 'pricing_tiers' ) -> insert ([
'tier_no' => $no , 'leads_in_tier' => $leads , 'price_per_lead_kopecks' => $kop ,
'is_active' => true , 'effective_from' => '2026-01-01' ,
'created_at' => now (), 'updated_at' => now (),
]);
}
// Действующая сетка — то, что клиент видит в «Биллинге».
foreach ([[ 1 , 500 , 5500 ], [ 2 , 500 , 5000 ], [ 3 , 1500 , 4500 ]] as [ $no , $leads , $kop ]) {
DB :: table ( 'pricing_tiers' ) -> insert ([
'tier_no' => $no , 'leads_in_tier' => $leads , 'price_per_lead_kopecks' => $kop ,
'is_active' => true , 'effective_from' => '2026-07-01' ,
'created_at' => now (), 'updated_at' => now (),
]);
}
}
it ( 'бот называет только ДЕЙСТВУЮЩУЮ сетку тарифов, а не все версии разом' , function () {
twoPricingGrids ();
$text = app ( LivePrices :: class ) -> substitute ( 'Сейчас сетка такая: {{тарифы}}.' );
// Действующие цены — на месте.
expect ( $text ) -> toContain ( '55 ₽' )
-> and ( $text ) -> toContain ( '50 ₽' )
-> and ( $text ) -> toContain ( '45 ₽' );
// Старая сетка — не должна попасть боту в рот ни одной цифрой.
expect ( $text ) -> not -> toContain ( '500 ₽' )
-> and ( $text ) -> not -> toContain ( '450 ₽' )
-> and ( $text ) -> not -> toContain ( '400 ₽' );
// И каждая ступень названа ровно один раз, а не по разу на каждую версию сетки.
expect ( substr_count ( $text , 'ступень 1' )) -> toBe ( 1 );
});
it ( 'на «сколько стоит заявка» есть готовая вилка — от дорогой ступени до дешёвой' , function () {
twoPricingGrids ();
$text = app ( LivePrices :: class ) -> substitute ( 'Заявка стоит {{вилка}}.' );
// Человек должен получить цифру сразу, а не «смотрите в Биллинге» (живой разговор
// 14.07.2026: клиент трижды переспросил «а цена то какая? от и до?»).
expect ( $text ) -> toContain ( 'от 55 ₽' )
-> and ( $text ) -> toContain ( 'до 45 ₽' ) // в тестовой сетке 3 ступени: 55 → 50 → 45
-> and ( $text ) -> not -> toContain ( '500' );
});
it ( 'нет действующей сетки — бот не выдумывает цифры, а отсылает в «Биллинг»' , function () {
DB :: table ( 'pricing_tiers' ) -> delete ();
$text = app ( LivePrices :: class ) -> substitute ( 'Сейчас сетка такая: {{тарифы}}.' );
expect ( $text ) -> toContain ( 'Биллинг' )
-> and ( $text ) -> not -> toContain ( '₽' );
});
it ( 'на вопрос про цену бот даёт разбор «Тарифы», а не «Сбор источников»' , function () {
twoPricingGrids ();
// У статьи про сбор источников слово «цена» стоит прямо в ЗАГОЛОВКЕ — на этом
// бот и спотыкался: она считалась «по теме» на любой денежный вопрос.
KnowledgeChunk :: create ([
'source_path' => 'help/izuchenie-istochnikov.md' ,
'title' => 'Собрать источники конкурента (шаг 2) — цена, очередь, повторный сбор' ,
'tour' => null , 'topics' => 'сбор источников, очередь, повторный сбор' ,
'chunk_index' => 0 , 'content' => 'Сбор источников стоит {{цена_изучения}} за конкурента.' ,
]);
KnowledgeChunk :: create ([
'source_path' => 'help/tarify.md' ,
'title' => 'Тарифы — сколько стоит заявка' ,
'tour' => null , 'topics' => 'тариф, тарифы, цена, сколько стоит заявка, цена за лид, ступень' ,
'chunk_index' => 0 , 'content' => 'Всего 7 ступеней. Сейчас сетка такая: {{тарифы}}.' ,
]);
llmAnswers ( 'Цена зависит от объёма: чем больше заявок за месяц, тем дешевле каждая.' );
askBot ( 'c-price' , 'Сколько стоит заявка? От и до?' );
$answer = ( string ) BotDialog :: where ( 'chat_id' , 'c-price' ) -> where ( 'direction' , 'out' ) -> value ( 'message' );
expect ( $answer ) -> toContain ( '/kak-eto-rabotaet#tarify' )
-> and ( $answer ) -> not -> toContain ( 'izuchenie-istochnikov' );
});
it ( 'короткое уточнение не утаскивает человека в чужую тему' , function () {
twoPricingGrids ();
// Ровно как на боевом: у статьи про сбор источников в синонимах стоит «50 рублей» —
// и реплика «а на скриншоте 50 рублей?» цепляет её крепче, чем «Тарифы».
KnowledgeChunk :: create ([
'source_path' => 'help/izuchenie-istochnikov.md' ,
'title' => 'Собрать источники конкурента (шаг 2) — цена, очередь, повторный сбор' ,
'tour' => null , 'topics' => 'сбор источников, 50 рублей, цена изучения, повторный сбор' ,
'chunk_index' => 0 , 'content' => 'Сбор источников стоит 10 ₽ за конкурента.' ,
]);
KnowledgeChunk :: create ([
'source_path' => 'help/tarify.md' ,
'title' => 'Тарифы — сколько стоит заявка' ,
'tour' => null , 'topics' => 'тариф, тарифы, цена, сколько стоит заявка, ступень' ,
'chunk_index' => 0 , 'content' => 'Всего 7 ступеней. Сейчас сетка такая: {{тарифы}}.' ,
]);
// Человек уже спросил про цену и получил разбор «Тарифы» — разговор идёт про тарифы.
BotDialog :: create ([ 'chat_id' => 'c-follow' , 'direction' => 'in' , 'message' => 'Сколько стоит заявка? От и до?' , 'source' => 'landing' , 'created_at' => now () -> subMinute ()]);
BotDialog :: create ([ 'chat_id' => 'c-follow' , 'direction' => 'out' , 'message' => " Цена зависит от объёма. \n \n 👉 Посмотрите по шагам: https://liderra.ru/kak-eto-rabotaet#tarify " , 'source' => 'landing' , 'created_at' => now () -> subMinute ()]);
llmAnswers ( 'Да, на скриншоте одна из ступеней сетки.' );
askBot ( 'c-follow' , 'А на скриншоте 50 рублей написано!?' );
$answer = ( string ) BotDialog :: where ( 'chat_id' , 'c-follow' ) -> where ( 'direction' , 'out' )
-> orderByDesc ( 'id' ) -> value ( 'message' );
// Лучше не дать ссылку вовсе, чем увести человека в разбор про сбор источников.
expect ( $answer ) -> not -> toContain ( 'izuchenie-istochnikov' );
});
it ( 'уверенным считается совпадение хотя бы на половину вопроса' , function () {
$sbor = KnowledgeChunk :: create ([
'source_path' => 'help/izuchenie-istochnikov.md' ,
'title' => 'Собрать источники конкурента (шаг 2) — цена, очередь, повторный сбор' ,
'tour' => null , 'topics' => 'сбор источников, цена изучения' , 'chunk_index' => 0 ,
'content' => 'Сбор источников стоит 10 ₽.' ,
]);
$search = app ( App\Services\Bot\KnowledgeSearch :: class );
// «Те цены для всех разные получается?» — совпало одно случайное слово из трёх.
expect ( $search -> confidentTopicMatch ( $sbor , 'Те цены для всех разные получается?' )) -> toBeFalse ();
// «сколько стоит сбор источников?» — статья и правда про это.
expect ( $search -> confidentTopicMatch ( $sbor , 'сколько стоит сбор источников?' )) -> toBeTrue ();
});
2026-07-14 15:49:53 +03:00
it ( 'гостю бот не приписывает его ступень и баланс — у гостя их нет' , function () {
twoPricingGrids ();
KnowledgeChunk :: create ([
'source_path' => 'help/tarify.md' , 'title' => 'Тарифы — сколько стоит заявка' , 'tour' => null ,
'topics' => 'тариф, цена, ступень, сколько стоит заявка' , 'chunk_index' => 0 ,
'content' => 'Всего 7 ступеней. Сейчас сетка такая: {{тарифы}}.' ,
]);
// Проверка на боевом 14.07.2026: гостю (он не вошёл, заявок у него нет) бот заявил
// «Вы сейчас на второй ступени (50 ₽), потому что уже получили заявки». Выдумка.
llmAnswers ( 'Вы сейчас на второй ступени (50 ₽), потому что в этом месяце уже получили заявки. На второй ступени цена заявки — 50 ₽, дальше она опускается.' );
askBot ( 'c-guest' , 'Сколько стоит заявка на второй ступени?' );
$answer = ( string ) BotDialog :: where ( 'chat_id' , 'c-guest' ) -> where ( 'direction' , 'out' ) -> value ( 'message' );
// Личное состояние — вырезано.
expect ( $answer ) -> not -> toContain ( 'Вы сейчас на второй ступени' )
-> and ( $answer ) -> not -> toContain ( 'уже получили заявки' );
// Безличное объяснение про сетку — осталось, человек не остался без ответа.
expect ( $answer ) -> toContain ( '50 ₽' );
});
it ( 'вошедшему клиенту его личные цифры называть можно — они из карточки фактов' , function () {
twoPricingGrids ();
KnowledgeChunk :: create ([
'source_path' => 'help/balans.md' , 'title' => 'Баланс' , 'tour' => null ,
'topics' => 'баланс, сколько денег' , 'chunk_index' => 0 ,
'content' => 'Баланс виден в разделе «Биллинг».' ,
]);
$guard = app ( App\Services\Bot\AnswerGuard :: class );
$facts = " ФАКТЫ КЛИЕНТА: \n баланс: 11 025 ₽ \n ступень: 2 " ;
$clean = $guard -> clean ( 'У вас сейчас на балансе 11 025 ₽.' , $facts , personalFactsKnown : true );
expect ( $clean ) -> toContain ( '11 025 ₽' );
});
2026-07-14 15:32:15 +03:00
it ( 'ответ не начинается с середины фразы, когда снята отговорка «в инструкции этого нет»' , function () {
KnowledgeChunk :: create ([
'source_path' => 'help/o-servise.md' , 'title' => 'О сервисе' , 'tour' => null ,
'topics' => 'о сервисе, чем отличается, конкуренты' , 'chunk_index' => 0 ,
'content' => 'Оплата за результат, абонентской платы нет.' ,
]);
llmAnswers ( 'Про других поставщиков в инструкции этого нет. Но если вы хотите понять, подходит ли вам Лидерра, расскажу, чем мы отличаемся: оплата только за результат.' );
askBot ( 'c-alt' , 'Скажи какие есть альтернативы вашему сервису?' );
$answer = ( string ) BotDialog :: where ( 'chat_id' , 'c-alt' ) -> where ( 'direction' , 'out' ) -> value ( 'message' );
expect ( $answer ) -> not -> toStartWith ( 'Но ' )
-> and ( $answer ) -> not -> toStartWith ( 'А ' )
-> and ( $answer ) -> toContain ( 'отличаемся' );
});