Files
portal/app/tests/Feature/Bot/KnowledgeSearchTest.php
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

127 lines
7.5 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\KnowledgeSearch;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
KnowledgeChunk::create([
'source_path' => '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();
});