Files
portal/app/tests/Feature/Bot/LiveDialogPriceFixesTest.php
T
Дмитрий 7b7718d319 fix(bot): бот врал про цены живым гостям — брал ВСЕ версии сетки тарифов разом
Живой разговор на боевом 14.07.2026: гость спросил цену, бот назвал «500 ₽ за первую
заявку месяца» (такого тарифа нет — сетка 55→25 ₽), клиент поймал на вранье и ушёл.

Три поломки из одного разговора:

1. ЦЕНЫ. В pricing_tiers лежат ЧЕТЫРЕ версии сетки (старые хранятся с датой начала
   действия). Портал берёт свежую через PricingTierRepository, а LivePrices читал
   таблицу напрямую и склеивал все версии: «ступень 1 — 500 ₽; ступень 1 — 70 ₽;
   ступень 1 — 55 ₽…». Сторож вранья был бессилен — 500 ₽ и правда лежало в поданных
   модели статьях. Теперь сетку берём тем же способом, что и «Биллинг» в кабинете.

2. ССЫЛКИ МИМО ТЕМЫ. У статьи «Собрать источники — цена, очередь…» слово «цена» стоит
   в заголовке, а в синонимах «50 рублей» — она перебивала «Тарифы» на любом денежном
   вопросе. Теперь берём САМУЮ совпавшую статью и только при уверенном совпадении
   (совпавшие слова покрывают хотя бы половину вопроса); на коротком уточнении новую
   ссылку не подсовываем, если человек уже получил одну.

3. ОТВЕТ С СЕРЕДИНЫ ФРАЗЫ. «Но если вы хотите понять…» — клиент решил, что ему хамят.
   Висящий союз снимался ДО того, как выбрасывалась отговорка «в инструкции этого нет».
   Порядок исправлен (AnswerGuard::polishStart).

Плюс по решению владельца: на «сколько стоит заявка?» бот сразу называет вилку
«от 55 ₽ за заявку до 25 ₽ при большом объёме» (метка {{вилка}} из живой сетки).

Тесты бота: 240/240, из них 6 новых — воспроизводят тот разговор и падали на старом коде.

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

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