Files
portal/app/tests/Feature/Bot/MassRunFixesTest.php
T
Дмитрий b1b1686b0e fix(bot): прогон 250 вопросов × 2 режима — 11 находок закрыто, все проверены перепрогоном
Владелец: «проверь их обоих на тех 250 вопросах» → «правь все».
Разбор: docs/superpowers/findings/2026-07-14-bot-250-both-modes-run.md

Цены оказались вылечены полностью (0 выдуманных цен на 500 ответов), но вылез другой
пласт — бот путал гостя с вошедшим клиентом и импровизировал там, где правды нет в статьях.

ЧТО ПОЧИНЕНО (было → стало на перепрогоне):
- Гостю приписывали счёт и подарок («у вас уже есть 1000 ₽, они лежат на счёте») 2 → 0.
  У гостя нет ни счёта, ни подарка: он не зарегистрирован.
- Вошедшему называли его баланс подарком портала 2 → 0 и подсовывали сценарий новичка
  («живите на подарок», «создайте первый проект») — подарок он потратил давно.
- Вошедшему врали про состояние проектов («два работают», а работает один) 4 → 0
  и про заявки («сегодня 12», а сегодня 0) 1 → 0.
- «Сотая заявка уже дешевле» 2 → 0: ступень меняется по ОБЪЁМУ (первая — 500 заявок).
- «1000 ₽ хватит на несколько десятков заявок» 1 → 0. По решению владельца число заявок
  на подарок НЕ называем вообще: сколько придёт, зависит от источника.
- «Поднимите лимит — заявок будет больше» 1 → 0: лимит это ПОТОЛОК, а не источник.
- «Такого не бывает» про списание без заявки 1 → 0: на проде фантомные списания БЫЛИ.
- «Для Москвы — Московскую область» 1 → 0: Москва и СПб — самостоятельные субъекты.
- Обрубки фраз после чистки сторожем («Они лежат…», «Нажмите её…») 3 → 0.

ЗАМЕЧАНИЕ ВЛАДЕЛЬЦА ПО ЖИВОМУ ЛОГУ: «номера это и есть заявки, а он их делит».
Фраза «мы не продаём номера поштучно» была ВЫДУМКОЙ бота — в статьях её нет.
Теперь: номер = заявка = лид = контакт, продаём любой объём (хоть одну штуку),
минимального заказа нет; спросили цену — бот называет цифру ПЕРВОЙ фразой.
Живьём: «Сколько стоит один номер?» → «Заявка стоит от 55 ₽ до 25 ₽».

ПРАВДА ПРО ВОЗВРАТ ДЕНЕГ вынесена в статью vozvrat-deneg.md — дословно из «Политики
возврата» (/legal/refund): остаток по заявлению на почту, до 3 рабочих дней, за вычетом
комиссий; стоимость переданных заявок не возвращается. Раньше бот придумывал процедуру.

Плюс: сторож больше не считает выдумкой число, названное САМИМ клиентом («можно купить
всего 5 номеров?»); демо-стенд перестал сам себе противоречить (счётчик проекта «сегодня»
разошёлся с числом сделок); добавлен прогонщик bot:run-questions для таких проверок.

Тесты: бот 258/258, весь бэкенд 2738/2744 (0 падений). Сторож не перестарался: из 250
старых одобренных ответов режет целиком 1.

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

356 lines
19 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\Models\KnowledgeChunk;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\KnowledgeSearch;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
/**
* Массовый прогон 250 вопросов через настоящий вебхук (12.07.2026): 37 «не знаю»,
* 4 ложных эскалации, враньё про деньги и хамство на приветствии.
* Каждый тест — конкретная находка прогона.
*/
beforeEach(function () {
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,
]);
KnowledgeChunk::query()->delete();
Artisan::call('help:rebuild-knowledge');
});
function answerOf(string $question): string
{
Http::fake(['*' => Http::response([
'result' => ['alternatives' => [['message' => ['text' => 'Ответ по фрагментам инструкции.']]]],
], 200)]);
return app(BotAnswerService::class)->answer($question)->text;
}
it('здоровается и благодарит по-человечески, а не просит телефон', function () {
// Прогон 12.07.2026: на «здравствуйте», «ало», «спасибо» бот отвечал
// «в моей инструкции нет ответа, оставьте телефон» — отшивал на первой реплике.
foreach (['здравствуйте', 'привет', 'добрый день', 'ало'] as $q) {
$answer = app(BotAnswerService::class)->answer($q);
expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT)
->and($answer->text)->not->toBe(BotAnswerService::ESCALATE_TEXT)
->and($answer->escalate)->toBeFalse()
->and($answer->text)->toContain('Здравствуйте');
}
foreach (['спасибо', 'спасибо большое', 'благодарю'] as $q) {
$answer = app(BotAnswerService::class)->answer($q);
expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT)
->and($answer->escalate)->toBeFalse()
->and($answer->text)->toContain('Пожалуйста');
}
});
it('на «ты бот или человек» отвечает честно и без телефона', function () {
$answer = app(BotAnswerService::class)->answer('ты человек или робот?');
expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT)
->and($answer->escalate)->toBeFalse()
->and(mb_strtolower($answer->text))->toContain('бот');
});
it('«соедините с оператором» — эскалация, а не отказ', function () {
// Прогон 12.07.2026: бот сухо отвечал «не могу соединить вас с оператором».
Http::fake();
foreach ([
'соедините с оператором',
'переключите на менеджера',
'свяжите меня с человеком',
'можно живого специалиста',
] as $q) {
expect(app(BotAnswerService::class)->answer($q)->text)->toBe(BotAnswerService::ESCALATE_TEXT);
}
});
it('не зовёт специалиста там, где ответ есть в инструкции', function () {
// Прогон 12.07.2026: слова «у меня», «менеджер», «мой проект» ложно уводили
// на живого специалиста, хотя ответ есть в статьях.
foreach ([
'у меня кончился баланс что будет',
'можно дать доступ менеджеру только к сделкам',
'где мой проект пропал',
] as $q) {
expect(answerOf($q))->not->toBe(BotAnswerService::ESCALATE_TEXT);
}
});
it('знает про бесплатный тест и подарок 1000 ₽ как про подарок, а не сумму пополнения', function () {
$hits = app(KnowledgeSearch::class)->search('есть бесплатный тестовый период?', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toContain('Бесплатный тест');
$text = collect($hits)->pluck('content')->implode(' ');
// Формулировка «лежит на ВАШЕМ балансе» убрана (прогон 250×2, 14.07.2026): с ней бот
// говорил ГОСТЮ «у вас уже есть 1000 ₽ на счёте», хотя счёта у него нет. Смысл тот же —
// подарок, а не сумма пополнения, — но без «вашего» счёта у незарегистрированного.
expect($text)->toContain('подарок')
->and($text)->toContain('уже лежит на балансе после регистрации');
});
it('знает, как работать с полученным номером: сначала СМС, через час звонок', function () {
foreach (['что говорить когда я звоню по вашему лиду', 'есть скрипт продаж'] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect(collect($hits)->pluck('content')->implode(' '))->toContain('СМС');
}
});
it('называет средние показатели, но не обещает результат', function () {
$hits = app(KnowledgeSearch::class)->search('какая конверсия в продажу у ваших лидов', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toContain('Средние показатели');
// Статья режется на куски — смотрим её целиком.
$article = KnowledgeChunk::query()
->where('source_path', 'help/srednie-pokazateli.md')
->orderBy('chunk_index')
->pluck('content')
->implode(' ');
expect($article)->toContain('812') // из 100 номеров готовы разговаривать
->and($article)->toContain('57') // ещё столько же откликается на СМС
->and($article)->toContain('не обещание'); // но гарантий не даём
});
it('знает про повторы номеров: один номер не вернётся 30 дней', function () {
foreach (['дубликаты номеров бывают?', 'один и тот же человек может прийти дважды'] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect(collect($hits)->pluck('content')->implode(' '))->toContain('30 дней');
}
});
/**
* Правило владельца 13.07.2026: недозвон — обычная часть работы, замены за него НЕТ.
* Раньше статья обещала «заменим» — бот повторял это клиентам, а компания не меняет.
* Отвечаем мягко: совет по работе с номером + спорный случай разбирает специалист.
*/
it('на «не берёт трубку» даёт совет и путь к специалисту, а не обещание замены', function () {
$hits = app(KnowledgeSearch::class)->search('а если номер недоступен или не берёт трубку', 3);
expect($hits)->not->toBeEmpty();
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('support@liderra.ru')
->and($text)->toContain('обычное дело')
->and($text)->not->toContain('заменим')
->and($text)->not->toContain('можем заменить');
});
it('знает про вывод остатка и отсутствие рассрочки', function () {
foreach (['можно вывести остаток денег обратно', 'есть ли рассрочка или постоплата'] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect($hits[0]->title)->toContain('Вывод остатка');
}
});
it('знает, что поддержка круглосуточная и телефона у неё нет', function () {
$hits = app(KnowledgeSearch::class)->search('когда работает поддержка', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toContain('Поддержка');
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('24/7')
->and($text)->toContain('Телефона поддержки у нас нет');
});
it('знает, как удалить аккаунт и свои данные', function () {
foreach (['как удалить аккаунт', 'хочу удалить свои данные из вашей базы'] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect(collect($hits)->pluck('content')->implode(' '))->toContain('support@liderra.ru');
}
});
it('не ограничивает ниши, но честно предупреждает про злоупотребление', function () {
$hits = app(KnowledgeSearch::class)->search('а для микрозаймов подойдет?', 3);
expect($hits)->not->toBeEmpty();
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('Ограничений по нишам')
->and($text)->toContain('блокировк');
});
it('не врёт про деньги: минуса нет, списание без заявки невозможно', function () {
$minus = app(KnowledgeSearch::class)->search('баланс ушел в минус, это как', 3);
expect(collect($minus)->pluck('content')->implode(' '))->toContain('В минус аккаунт');
$charged = app(KnowledgeSearch::class)->search('списали деньги а заявки не пришли', 3);
expect(collect($charged)->pluck('title')->implode(' | '))->toContain('Списания');
// Статья режется на куски — сам факт смотрим по всей статье.
$article = KnowledgeChunk::query()
->where('source_path', 'help/spisaniya.md')
->orderBy('chunk_index')
->pluck('content')
->implode(' ');
expect($article)->toContain('так тоже не бывает');
});
it('не врёт про выходные: заявки идут в отмеченные дни приёма', function () {
$hits = app(KnowledgeSearch::class)->search('в выходные заявки идут?', 3);
expect($hits)->not->toBeEmpty();
expect(collect($hits)->pluck('content')->implode(' '))->toContain('отмеченные дни');
});
it('сделку удалить нельзя — и статья это прямо говорит', function () {
$hits = app(KnowledgeSearch::class)->search('можно ли удалить сделку', 3);
expect($hits)->not->toBeEmpty();
expect(collect($hits)->pluck('content')->implode(' '))->toContain('Удалить сделку нельзя');
});
it('строгий поиск не перебивает более точное мягкое совпадение', function () {
// Второй прогон 12.07.2026: «сколько стоит одна заявка» ушло в «не знаю». Строгий
// поиск (все слова разом) нашёл «Создать проект» — там случайно есть все слова —
// и не дал мягкому поиску показать «Тарифы», где эти слова стоят в заголовке.
$cases = [
'сколько стоит одна заявка' => 'Тарифы',
'когда придут деньги после оплаты по счету' => 'Оплата по счёту',
];
foreach ($cases as $q => $expected) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect($hits[0]->title)->toContain($expected);
}
});
it('отвечает на остальные вопросы второго прогона', function () {
$cases = [
'сколько заявок пришло за неделю' => 'Дашборд',
'почему так дорого' => 'Тарифы',
'вы это таргет или что' => 'О сервисе',
'что значит непроверенный номер' => 'Пометки',
'вы храните мои данные где, за границей?' => 'Безопасность',
'сайт тормозит' => 'Поддержка',
];
foreach ($cases as $q => $expected) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect($hits[0]->title)->toContain($expected);
}
});
it('закрывает последние пять провалов третьего прогона', function () {
$cases = [
'как включить 2fa' => 'Безопасность',
'работаете с ндс?' => 'Договор',
'что писать в поле ниша' => 'Собрать конкурентов',
];
foreach ($cases as $q => $expected) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect($hits[0]->title)->toContain($expected);
}
// «Сколько у вас клиентов» и «есть отзывы» — статья находилась, но ИИ считал
// «мы не раскрываем клиентов» за отсутствие ответа. Ответ должен быть прямым.
$hits = app(KnowledgeSearch::class)->search('сколько клиентов у вас уже есть', 3);
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('Сколько у вас клиентов') // вопрос назван прямо
->and($text)->toContain('не раскрываем'); // и на него дан прямой ответ
});
it('закрывает два последних провала четвёртого прогона', function () {
$cases = [
'где посмотреть из какого проекта пришла заявка' => 'Сделки',
'а если лиды не окупятся' => 'Средние показатели',
];
foreach ($cases as $q => $expected) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect($hits[0]->title)->toContain($expected);
}
});
it('на непонятную реплику переспрашивает, а не просит телефон', function () {
// Второй прогон: «не работает», «как» — бот сдавался и просил телефон.
foreach (['не работает', 'как', 'ничего не понятно'] as $q) {
$answer = app(BotAnswerService::class)->answer($q);
expect($answer->text)->not->toBe(BotAnswerService::UNKNOWN_TEXT)
->and($answer->escalate)->toBeFalse()
->and($answer->text)->toContain('Уточните');
}
});
it('короткий вопрос-продолжение ищет ответ с учётом прошлой реплики', function () {
// Замечание владельца 12.07.2026: «у бота нет контекста?». Память у ИИ есть (6 реплик),
// а ПОИСК смотрел только на текущую фразу: на «а сколько это стоит?» после разговора
// про сбор конкурентов он приносил общие «Тарифы» — и ИИ отвечал не по тем бумагам.
// Без контекста «а это платно?» не ведёт к статье про сбор источников.
expect(app(KnowledgeSearch::class)->search('а это платно?', 3))
->each(fn ($hit) => $hit->title->not->toContain('Собрать источники'));
$history = [
['role' => 'user', 'text' => 'как собрать источники конкурента'],
['role' => 'assistant', 'text' => 'В карточке конкурента — кнопка «Собрать источники».'],
];
Http::fake(['*' => Http::response([
'result' => ['alternatives' => [['message' => ['text' => 'Ответ по фрагментам.']]]],
], 200)]);
$answer = app(BotAnswerService::class)->answer('а это платно?', $history);
$titles = KnowledgeChunk::query()
->whereIn('id', $answer->matchedChunkIds)
->pluck('title')
->implode(' | ');
expect($titles)->toContain('Собрать источники конкурента');
});
it('не предлагает экскурсию, если статья подошла случайно — по слову из текста', function () {
// Массовый прогон 12.07.2026: около двадцати ответов заканчивались ссылкой
// «Показать на портале» на экскурсию не по теме — спросили про регистрацию,
// а бот вёл на импорт данных. Экскурсию даём, только если статья реально по теме:
// слово из вопроса попало в её заголовок или синонимы, а не просто в текст.
config()->set('services.bot.tours_enabled', true);
config()->set('app.url', 'https://liderra.ru');
KnowledgeChunk::query()->delete();
KnowledgeChunk::create([
'source_path' => 'help/ch.md', 'title' => 'Списания', 'tour' => 'charges',
'topics' => 'списание, за что списали', 'chunk_index' => 0,
'content' => 'Списания можно выгрузить в Excel одной кнопкой.',
]);
// Вопрос про Excel цепляет статью только текстом — экскурсии по списканиям тут не место.
expect(answerOf('как выгрузить в excel'))->not->toContain('?tour=');
// Вопрос про списания — по теме статьи: экскурсия уместна.
expect(answerOf('за что списали деньги'))->toContain('?tour=charges');
});
it('не обещает готовой интеграции с амоCRM', function () {
$hits = app(KnowledgeSearch::class)->search('интеграция с амосрм есть?', 3);
expect($hits)->not->toBeEmpty();
expect(collect($hits)->pluck('content')->implode(' '))->toContain('Готовых интеграций');
});