'help/p.md', 'title' => 'Что такое проект', 'tour' => 'create-project', 'topics' => 'создать проект, заявка на лиды', 'chunk_index' => 0, 'content' => 'Проект — это заявка на поток клиентов с выбранного источника.', ]); KnowledgeChunk::create([ 'source_path' => 'help/b.md', 'title' => 'Как пополнить баланс', 'tour' => 'top-up-balance', 'topics' => 'пополнить, закинуть деньги, оплата', 'chunk_index' => 0, 'content' => 'Пополнить баланс: раздел Биллинг, кнопка Пополнить.', ]); }); it('находит релевантный чанк и ранжирует его первым', function () { $hits = app(KnowledgeSearch::class)->search('а что такое проект?', 3); expect($hits)->not->toBeEmpty() ->and($hits[0]->title)->toBe('Что такое проект'); }); it('находит по синонимам из topics («закинуть деньги»)', function () { $hits = app(KnowledgeSearch::class)->search('как закинуть деньги', 3); expect($hits)->not->toBeEmpty() ->and($hits[0]->title)->toBe('Как пополнить баланс'); }); it('находит статью, даже если часть слов вопроса в текстах отсутствует', function () { // websearch_to_tsquery связывает слова через И: слово «нужен» в статье не встречается, // строгий запрос даёт пусто — нужен мягкий запасной поиск через ИЛИ (живой баг 12.07.2026). $hits = app(KnowledgeSearch::class)->search('Что такое проект и зачем он нужен?', 3); expect($hits)->not->toBeEmpty() ->and($hits[0]->title)->toBe('Что такое проект'); }); it('на РЕАЛЬНОЙ базе знаний «зачем нужна Лидерра, чем отличается» ведёт к статье о сервисе', function () { // Живой урок 12.07.2026: бот принял «конкурентов» буквально и рассказал про раздел // «Конкурентное поле» вместо ценности сервиса. Проверяем на настоящих статьях // resources/help — в базе знаний обязана быть статья про смысл и выгоду сервиса. KnowledgeChunk::query()->delete(); $this->artisan('help:rebuild-knowledge')->assertExitCode(0); $hits = app(KnowledgeSearch::class)->search( 'а зачем лидерра вообще нужна и чем она отличается от конкурентов', 3 ); expect($hits)->not->toBeEmpty() ->and($hits[0]->title)->not->toContain('Конкурентное поле') ->and(mb_strtolower($hits[0]->title))->toContain('сервис'); }); it('«где вы берёте эти номера» → статья о происхождении данных и согласии абонента', function () { // Живой урок 12.07.2026 (формулировка владельца): номера передают операторы связи, // и только по тем абонентам, кто дал согласие на передачу данных третьим лицам // и на рекламные предложения. Раньше бот эскалировал этот частый вопрос. KnowledgeChunk::query()->delete(); $this->artisan('help:rebuild-knowledge')->assertExitCode(0); foreach (['а где вы берёте эти номера?', 'откуда у вас данные людей', 'это законно вообще?'] as $q) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty() ->and(collect($hits)->pluck('content')->implode(' '))->toContain('оператор'); } }); it('вопросы про дубли ведут в статью о дублях — и она знает про ОБА места', function () { // Живой урок 12.07.2026: на «а дубли в карточке конкурента?» бот выдумал несуществующую // чистку внутри карточки. На самом деле кнопка «Найти и объединить дубли» есть в ДВУХ // местах — в «Поле» и в «Предложениях» — плюс окно «такой источник уже есть». KnowledgeChunk::query()->delete(); $this->artisan('help:rebuild-knowledge')->assertExitCode(0); foreach ([ 'зачем чистка дублей в конкурентном поле?', 'что такое почистить дубли', 'а дубли в карточке конкурента?', 'как объединить одинаковых конкурентов', ] as $q) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect($hits[0]->title)->toContain('Дубли конкурентов'); } // Статья режется на куски — смотрим её целиком: в ней обязаны быть ОБА места чистки. $article = KnowledgeChunk::query() ->where('source_path', 'help/dubli-konkurentov.md') ->orderBy('chunk_index') ->pluck('content') ->implode(' '); expect($article)->toContain('В «Поле»') // первое место чистки ->and($article)->toContain('В «Предложениях»') // второе место чистки ->and($article)->toContain('уже есть'); // окно «такой источник уже есть» }); it('«проекты или конкурентное поле — что выбрать» находит ответ, а не «не знаю»', function () { // Живой урок 12.07.2026: владелец спросил «чем лучше пользоваться, проектами или // конкурентным полем? по мне одно и то же» — бот честно сдался. Ответ обязан быть. KnowledgeChunk::query()->delete(); $this->artisan('help:rebuild-knowledge')->assertExitCode(0); foreach (['что выбрать проекты или конкурентное поле', 'чем лучше пользоваться проектами или полем'] as $q) { $hits = app(KnowledgeSearch::class)->search($q, 3); expect($hits)->not->toBeEmpty(); expect(collect($hits)->pluck('content')->implode(' '))->toContain('Что выбрать'); } }); it('на вопрос не по теме возвращает пусто', function () { expect(app(KnowledgeSearch::class)->search('какая погода в москве', 3))->toBeEmpty(); }); it('не падает на спецсимволах в вопросе', function () { expect(app(KnowledgeSearch::class)->search('проект & | ! ( )', 3))->toBeArray(); });