Files
portal/app/tests/Feature/Bot/LiveDialogPriceFixesTest.php
T
Дмитрий a2bd2dbc8b fix(bot): гостю больше нельзя приписывать его ступень, баланс и заявки
Проверка на боевом 14.07.2026 (сразу после выката фикса цен): гость — человек не вошёл,
никаких заявок у него нет — показал скриншот с 50 ₽, и бот ответил: «Вы сейчас на второй
ступени (50 ₽), потому что в этом месяце уже получили заявки». Выдумка про человека.

Причина: сторож сверяет личные цифры только у ВОШЕДШЕГО (карточка фактов). У гостя
карточки нет — и модель фантазировала свободно.

Теперь AnswerGuard знает, посчитаны ли личные цифры собеседника. Если нет (гость) —
режет фразы, утверждающие его нынешнее состояние: «вы сейчас на… ступени», «у вас
на балансе N», «вы уже получили заявки». Общие объяснения не трогаются: «вы платите
только за полученные заявки», «когда наберёте объём — перейдёте на следующую ступень».

Тесты бота: 242/242 (+2 новых).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:49:53 +03:00

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