7b7718d319
Живой разговор на боевом 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>
222 lines
13 KiB
PHP
222 lines
13 KiB
PHP
<?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('отличаемся');
|
||
});
|