236 lines
14 KiB
PHP
236 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\KnowledgeChunk;
|
|
use App\Services\Bot\BotAnswerService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
/**
|
|
* Экскурсии «Показать на портале» 13.07.2026 были ВЫКЛЮЧЕНЫ: ссылки уходили не по теме
|
|
* вопроса (совпадало одно случайное слово — бот приписывал экскурсию по уведомлениям
|
|
* к реплике «первое сообщение подряд»). Правило переписано, экскурсии включены обратно.
|
|
*
|
|
* Теперь их держат два условия, и оба обязательны:
|
|
* 1. Вопрос по теме статьи (KnowledgeSearch::isOnTopic — тесты в ToursRelevanceTest).
|
|
* 2. Клиент пишет ИЗ КАБИНЕТА. Гостю с лендинга ссылка внутрь кабинета бесполезна:
|
|
* он там ещё не зарегистрирован, и показывать ему нечего.
|
|
*
|
|
* Гостю вместо экскурсии в кабинет даём ссылку на ПОШАГОВЫЙ РАЗБОР (кадры со стрелками)
|
|
* на странице «Как это работает» (задача 2026-07-13). Раньше связь шла через карту
|
|
* BotAnswerService::LANDING_SHOWS (имя экскурсии → якорь, всего 4 темы) — она выброшена:
|
|
* разборов стало 20, и их якорь = СЛАГ СТАТЬИ (source_path без ".md"). Список слагов,
|
|
* у которых разбор реально существует, — config/guides.php.
|
|
*
|
|
* Этот сторож не даёт незаметно вернуть ссылки гостям без разбора и не даёт незаметно
|
|
* заменить кабинетную экскурсию вошедшего клиента на ссылку на лендинг.
|
|
*/
|
|
beforeEach(function () {
|
|
config()->set('services.bot.tours_enabled', true);
|
|
config()->set('app.url', 'https://lk.liderra.ru');
|
|
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,
|
|
]);
|
|
Http::fake([
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект создаётся в разделе «Проекты».']]]],
|
|
]),
|
|
]);
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'sozdat-proekt.md', 'title' => 'Как создать проект', 'tour' => 'create-project',
|
|
'topics' => 'создать проект, новый проект', 'chunk_index' => 0,
|
|
'content' => 'Проект создаётся в разделе «Проекты», кнопка «Создать».',
|
|
]);
|
|
});
|
|
|
|
it('клиенту из кабинета экскурсия по теме предлагается', function () {
|
|
$answer = app(BotAnswerService::class)->answer('как создать проект?', [], fromPortal: true);
|
|
|
|
expect($answer->text)->toContain('https://lk.liderra.ru/?tour=create-project');
|
|
});
|
|
|
|
it('гостю с лендинга экскурсию не предлагаем — ему нечего показывать в кабинете', function () {
|
|
$answer = app(BotAnswerService::class)->answer('как создать проект?', [], fromPortal: false);
|
|
|
|
expect($answer->text)->not->toContain('?tour=');
|
|
});
|
|
|
|
it('гостю даёт ссылку на разбор по слагу статьи', function () {
|
|
config()->set('guides.slugs', ['sozdat-proekt']);
|
|
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
|
|
|
$answer = app(BotAnswerService::class)->answer('как создать проект?', [], fromPortal: false);
|
|
|
|
expect($answer->text)->toContain('https://liderra.ru/kak-eto-rabotaet#sozdat-proekt');
|
|
expect($answer->text)->not->toContain('Показать на портале');
|
|
});
|
|
|
|
it('молчит, если разбора для статьи нет', function () {
|
|
config()->set('guides.slugs', ['sozdat-proekt']);
|
|
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'podderzhka.md', 'title' => 'Как связаться с поддержкой', 'tour' => 'support',
|
|
'topics' => 'поддержка, связаться, написать в поддержку',
|
|
'chunk_index' => 0,
|
|
'content' => 'Поддержка отвечает в чате и на support@liderra.ru.',
|
|
]);
|
|
Http::fake([
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Напишите в чат или на support@liderra.ru.']]]],
|
|
]),
|
|
]);
|
|
|
|
$answer = app(BotAnswerService::class)->answer('как связаться с поддержкой?', [], fromPortal: false);
|
|
|
|
expect($answer->text)->not->toContain('kak-eto-rabotaet');
|
|
expect($answer->text)->not->toContain('?tour=');
|
|
});
|
|
|
|
it('вошедшему по-прежнему даёт экскурсию в кабинет, а не ссылку на лендинг', function () {
|
|
config()->set('guides.slugs', ['sozdat-proekt']);
|
|
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
|
|
|
$answer = app(BotAnswerService::class)->answer('как создать проект?', [], fromPortal: true);
|
|
|
|
expect($answer->text)->toContain('Показать на портале');
|
|
expect($answer->text)->not->toContain('kak-eto-rabotaet');
|
|
});
|
|
|
|
it('на приветствие («привет») ссылок нет никому', function () {
|
|
config()->set('guides.slugs', ['sozdat-proekt']);
|
|
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
|
|
|
$guest = app(BotAnswerService::class)->answer('привет', [], fromPortal: false);
|
|
$client = app(BotAnswerService::class)->answer('привет', [], fromPortal: true);
|
|
|
|
expect($guest->text)->not->toContain('kak-eto-rabotaet');
|
|
expect($client->text)->not->toContain('Показать на портале');
|
|
});
|
|
|
|
/**
|
|
* Задача 13.07.2026: слаг/тур брались строго у ПЕРВОЙ найденной статьи ($chunks[0]).
|
|
* Если у неё разбора/экскурсии не было — гость (или клиент) оставался вовсе без ссылки,
|
|
* хотя вторая-третья найденная статья по той же теме её имела. Теперь перебираем
|
|
* найденные статьи по порядку и берём первую, что одновременно (а) по теме вопроса
|
|
* и (б) имеет нужное — слаг из config('guides.slugs') для гостя, tour для клиента.
|
|
*/
|
|
it('гостю даёт ссылку на разбор ВТОРОЙ найденной статьи, если у первой разбора нет', function () {
|
|
config()->set('guides.slugs', ['sdelki']);
|
|
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
|
|
|
// Заголовок содержит слово вопроса → выше headline_rank → находится ПЕРВОЙ.
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'zamena-zayavki.md', 'title' => 'Если недозвон', 'tour' => null,
|
|
'topics' => 'недозвон, не берёт трубку', 'chunk_index' => 0,
|
|
'content' => 'Недозвон — это не брак, замены заявки за него нет.',
|
|
]);
|
|
// Слово вопроса — только в синонимах (не в заголовке) → ниже headline_rank → ВТОРАЯ.
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'sdelki.md', 'title' => 'Сделки в кабинете', 'tour' => 'deals-tour',
|
|
'topics' => 'сделки, воронка, недозвон', 'chunk_index' => 0,
|
|
'content' => 'Сделки видно в разделе «Сделки».',
|
|
]);
|
|
|
|
$answer = app(BotAnswerService::class)->answer('недозвон?', [], fromPortal: false);
|
|
|
|
expect($answer->text)->toContain('https://liderra.ru/kak-eto-rabotaet#sdelki');
|
|
});
|
|
|
|
it('вошедшему даёт экскурсию второй статьи, если у первой её нет', function () {
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'balans-info.md', 'title' => 'Баланс аккаунта', 'tour' => null,
|
|
'topics' => 'баланс, счёт', 'chunk_index' => 0,
|
|
'content' => 'Баланс показан на дашборде.',
|
|
]);
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'balans-tour.md', 'title' => 'Пополнение счёта', 'tour' => 'balance-tour',
|
|
'topics' => 'баланс, пополнение', 'chunk_index' => 0,
|
|
'content' => 'Пополнить можно в разделе «Биллинг».',
|
|
]);
|
|
|
|
$answer = app(BotAnswerService::class)->answer('баланс?', [], fromPortal: true);
|
|
|
|
expect($answer->text)->toContain('?tour=balance-tour');
|
|
});
|
|
|
|
it('молчит, если НИ У ОДНОЙ найденной статьи нет разбора', function () {
|
|
config()->set('guides.slugs', ['other-guide-that-matches-nothing']);
|
|
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
|
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'zamena-zayavki.md', 'title' => 'Если недозвон', 'tour' => null,
|
|
'topics' => 'недозвон, не берёт трубку', 'chunk_index' => 0,
|
|
'content' => 'Недозвон — это не брак, замены заявки за него нет.',
|
|
]);
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'sdelki.md', 'title' => 'Сделки в кабинете', 'tour' => 'deals-tour',
|
|
'topics' => 'сделки, воронка, недозвон', 'chunk_index' => 0,
|
|
'content' => 'Сделки видно в разделе «Сделки».',
|
|
]);
|
|
|
|
$answer = app(BotAnswerService::class)->answer('недозвон?', [], fromPortal: false);
|
|
|
|
expect($answer->text)->not->toContain('kak-eto-rabotaet');
|
|
expect($answer->text)->not->toContain('?tour=');
|
|
});
|
|
|
|
/**
|
|
* Живой прогон 13.07.2026: короткая реплика-продолжение («как удалить аккаунт» сразу после
|
|
* вопроса про недозвон) склеивается с прошлой репликой ради контекста ОТВЕТА — и склеенный
|
|
* запрос по совпадению стема «удал-» нашёл первой совсем другую статью с разбором вместо
|
|
* нужной статьи без разбора. Ссылку нужно выбирать по ЧИСТОМУ текущему вопросу, не по
|
|
* склеенному с историей — иначе бот приписывает разбор не по той теме.
|
|
*/
|
|
it('короткая реплика-продолжение не даёт ссылку от статьи, найденной только из-за склейки с историей', function () {
|
|
config()->set('guides.slugs', ['temp-wrong-guide']);
|
|
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
|
|
|
// Настоящая тема вопроса — без разбора.
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'temp-no-guide.md', 'title' => 'Удалить временный файл', 'tour' => null,
|
|
'topics' => 'удалить, файл, временный', 'chunk_index' => 0,
|
|
'content' => 'Файл можно удалить в разделе «Настройки».',
|
|
]);
|
|
// Всплывает ТОЛЬКО из-за склейки с прошлой репликой; со «стемом» «удал-» совпадает
|
|
// с текущим вопросом случайно (не по теме) и имеет разбор в конфиге.
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'temp-wrong-guide.md', 'title' => 'Черновик документа', 'tour' => 'wrong-tour',
|
|
'topics' => 'удалить, черновик, шаблон', 'chunk_index' => 0,
|
|
'content' => 'Черновик хранится в разделе «Документы».',
|
|
]);
|
|
|
|
$history = [
|
|
['role' => 'user', 'text' => 'расскажи про черновик и шаблон'],
|
|
['role' => 'assistant', 'text' => 'Черновик хранится в разделе «Документы».'],
|
|
];
|
|
|
|
$answer = app(BotAnswerService::class)->answer('удалить файл?', $history, fromPortal: false);
|
|
|
|
expect($answer->text)->not->toContain('kak-eto-rabotaet');
|
|
expect($answer->text)->not->toContain('?tour=');
|
|
});
|
|
|
|
it('статья не по теме вопроса не даёт ссылку, даже если разбор у неё есть', function () {
|
|
config()->set('guides.slugs', ['sdelki']);
|
|
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
|
|
|
// Разбор для 'sdelki' сконфигурирован и есть в базе, но слово вопроса совпадает
|
|
// ТОЛЬКО с текстом статьи — не с заголовком и не с синонимами. isOnTopic() должна
|
|
// вернуть false, и ссылка не должна появиться, даже если статья найдена поиском.
|
|
KnowledgeChunk::create([
|
|
'source_path' => 'sdelki.md', 'title' => 'Пополнение баланса', 'tour' => 'deals-tour',
|
|
'topics' => 'пополнение, баланс', 'chunk_index' => 0,
|
|
'content' => 'Остаток можно уточнить у менеджера в переписке.',
|
|
]);
|
|
|
|
$answer = app(BotAnswerService::class)->answer('менеджера?', [], fromPortal: false);
|
|
|
|
expect($answer->text)->not->toContain('kak-eto-rabotaet');
|
|
expect($answer->text)->not->toContain('?tour=');
|
|
});
|