Files
portal/app/tests/Feature/Bot/LiveDialogPriceFixesTest.php
T

263 lines
16 KiB
PHP
Raw Normal View History

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