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(); }); 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 ₽'); }); 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('отличаемся'); });