a2bd2dbc8b
Проверка на боевом 14.07.2026 (сразу после выката фикса цен): гость — человек не вошёл, никаких заявок у него нет — показал скриншот с 50 ₽, и бот ответил: «Вы сейчас на второй ступени (50 ₽), потому что в этом месяце уже получили заявки». Выдумка про человека. Причина: сторож сверяет личные цифры только у ВОШЕДШЕГО (карточка фактов). У гостя карточки нет — и модель фантазировала свободно. Теперь AnswerGuard знает, посчитаны ли личные цифры собеседника. Если нет (гость) — режет фразы, утверждающие его нынешнее состояние: «вы сейчас на… ступени», «у вас на балансе N», «вы уже получили заявки». Общие объяснения не трогаются: «вы платите только за полученные заявки», «когда наберёте объём — перейдёте на следующую ступень». Тесты бота: 242/242 (+2 новых). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
16 KiB
PHP
263 lines
16 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 () {
|
||
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('отличаемся');
|
||
});
|