feat(bot): гостю - ссылка на пошаговый разбор по слагу статьи
This commit is contained in:
@@ -138,28 +138,6 @@ class BotAnswerService
|
||||
/** Слова-«пустышки»: сами по себе не несут темы. */
|
||||
private const VAGUE_PATTERN = '/^\s*(как|что|где|почему|зачем|не работает|ничего не понятно|не понятно|непонятно|помогите|проблема|ошибка)\W*$/iu';
|
||||
|
||||
/**
|
||||
* Карта «экскурсия в кабинете (KnowledgeChunk::tour) → якорь показа на лендинге».
|
||||
* Гость с лендинга не может открыть экскурсию внутри кабинета (не зарегистрирован),
|
||||
* но владелец просил показать ему то же самое на примере — раздел «Как это работает»
|
||||
* на лендинге с якорями по четырём темам (задача 2026-07-13). Ключи — НАСТОЯЩИЕ имена
|
||||
* экскурсий из frontmatter статей (resources/help/*.md, поле tour), не выдуманные:
|
||||
* create-project ← resources/help/sozdat-proekt.md
|
||||
* top-up-balance ← resources/help/kak-popolnit-balans.md
|
||||
* deals ← resources/help/sdelki.md
|
||||
* konkurentnoe-pole ← resources/help/konkurentnoe-pole.md
|
||||
* Экскурсии, для которых на лендинге ещё нет показа, в карте не появляются —
|
||||
* тогда гостю просто не дописываем ссылку (молчим, как раньше).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const LANDING_SHOWS = [
|
||||
'create-project' => 'kak-sozdat-proekt',
|
||||
'top-up-balance' => 'kak-popolnit-balans',
|
||||
'deals' => 'gde-smotret-zayavki',
|
||||
'konkurentnoe-pole' => 'konkurentnoe-pole',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly KnowledgeSearch $search,
|
||||
private readonly LlmClient $gpt,
|
||||
@@ -378,12 +356,22 @@ class BotAnswerService
|
||||
if ($fromPortal) {
|
||||
// Вошедшему — ссылка внутрь кабинета, как раньше.
|
||||
$text .= "\n\n👉 Показать на портале: ".rtrim((string) config('app.url'), '/').'/?tour='.$tour;
|
||||
} elseif (isset(self::LANDING_SHOWS[$tour])) {
|
||||
// Гостю с лендинга — тот же показ, но снаружи: якорь на лендинге. Если для
|
||||
// этой экскурсии якоря ещё нет — молчим, ничего не выдумываем.
|
||||
$anchor = self::LANDING_SHOWS[$tour];
|
||||
$text .= "\n\n👉 Посмотрите, как это выглядит: "
|
||||
.rtrim((string) config('services.bot.landing_url'), '/').'/#'.$anchor;
|
||||
} else {
|
||||
// Гостю — пошаговый разбор на лендинге. Связь по СЛАГУ СТАТЬИ (sozdat-proekt.md →
|
||||
// sozdat-proekt): экскурсия в кабинете и разбор для гостя — разные вещи, вязать их
|
||||
// через имя экскурсии значило держать вторую карту имён и путаться.
|
||||
//
|
||||
// NB: берём basename без расширения (не просто срезаем ".md"), потому что
|
||||
// HelpRebuildKnowledgeCommand кладёт source_path с префиксом "help/"
|
||||
// (см. app/Console/Commands/HelpRebuildKnowledgeCommand.php:35) — срез
|
||||
// только суффикса ".md" оставил бы слаг "help/sozdat-proekt" и ссылка
|
||||
// на лендинг никогда бы не совпала со списком config/guides.php.
|
||||
$slug = pathinfo((string) $chunks[0]->source_path, PATHINFO_FILENAME);
|
||||
if (in_array($slug, (array) config('guides.slugs', []), true)) {
|
||||
$text .= "\n\n👉 Посмотрите по шагам, как это делается: "
|
||||
.rtrim((string) config('services.bot.landing_url'), '/')
|
||||
.'/kak-eto-rabotaet#'.$slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Слаги статей помощи, у которых есть ПОШАГОВЫЙ РАЗБОР на странице «Как это работает».
|
||||
* Гостю бот даёт ссылку только на них — в пустоту не отправляем.
|
||||
* Что снято — видно в docs/superpowers/prototypes/guides/index.json.
|
||||
*/
|
||||
return [
|
||||
'slugs' => [
|
||||
'sozdat-proekt', 'proekt-iz-istochnika', 'konkurentnoe-pole', 'sbor-konkurentov',
|
||||
'izuchenie-istochnikov', 'dobavit-vruchnuyu', 'dubli-konkurentov', 'metki-nomerov',
|
||||
'sdelki', 'statusy-sdelok', 'kanban', 'dashboard', 'eksport-sdelok', 'otchety',
|
||||
'kak-popolnit-balans', 'spisaniya', 'tarify', 'pauza-proekta', 'limit-zayavok',
|
||||
'smena-istochnika',
|
||||
],
|
||||
];
|
||||
@@ -19,7 +19,14 @@ uses(RefreshDatabase::class);
|
||||
* 2. Клиент пишет ИЗ КАБИНЕТА. Гостю с лендинга ссылка внутрь кабинета бесполезна:
|
||||
* он там ещё не зарегистрирован, и показывать ему нечего.
|
||||
*
|
||||
* Этот сторож не даёт незаметно вернуть ссылки гостям.
|
||||
* Гостю вместо экскурсии в кабинет даём ссылку на ПОШАГОВЫЙ РАЗБОР (кадры со стрелками)
|
||||
* на странице «Как это работает» (задача 2026-07-13). Раньше связь шла через карту
|
||||
* BotAnswerService::LANDING_SHOWS (имя экскурсии → якорь, всего 4 темы) — она выброшена:
|
||||
* разборов стало 20, и их якорь = СЛАГ СТАТЬИ (source_path без ".md"). Список слагов,
|
||||
* у которых разбор реально существует, — config/guides.php.
|
||||
*
|
||||
* Этот сторож не даёт незаметно вернуть ссылки гостям без разбора и не даёт незаметно
|
||||
* заменить кабинетную экскурсию вошедшего клиента на ссылку на лендинг.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
config()->set('services.bot.tours_enabled', true);
|
||||
@@ -35,7 +42,7 @@ beforeEach(function () {
|
||||
]),
|
||||
]);
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => 'help/create.md', 'title' => 'Как создать проект', 'tour' => 'create-project',
|
||||
'source_path' => 'sozdat-proekt.md', 'title' => 'Как создать проект', 'tour' => 'create-project',
|
||||
'topics' => 'создать проект, новый проект', 'chunk_index' => 0,
|
||||
'content' => 'Проект создаётся в разделе «Проекты», кнопка «Создать».',
|
||||
]);
|
||||
@@ -53,57 +60,54 @@ it('гостю с лендинга экскурсию не предлагаем
|
||||
expect($answer->text)->not->toContain('?tour=');
|
||||
});
|
||||
|
||||
/**
|
||||
* Гостю с лендинга ссылка внутрь кабинета бесполезна, но ему тоже нужно «показать
|
||||
* на примере» — на лендинге готовится раздел «Как это работает» с якорями показа
|
||||
* по темам. Карта LANDING_SHOWS в BotAnswerService связывает имя экскурсии
|
||||
* (то же поле tour, что и для кабинета) с якорем на лендинге.
|
||||
*/
|
||||
it('гостю с лендинга даём ссылку на показ ТАМ ЖЕ, где кабинету — экскурсию', function () {
|
||||
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-sozdat-proekt');
|
||||
expect($answer->text)->toContain('https://liderra.ru/kak-eto-rabotaet#sozdat-proekt');
|
||||
expect($answer->text)->not->toContain('Показать на портале');
|
||||
});
|
||||
|
||||
it('вошедшему клиенту по-прежнему только ссылка в кабинет, без ссылки на лендинг', function () {
|
||||
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('liderra.ru/#');
|
||||
expect($answer->text)->not->toContain('kak-eto-rabotaet');
|
||||
});
|
||||
|
||||
it('гостю не даём ссылку, если у статьи по теме нет показа на лендинге в карте', function () {
|
||||
config()->set('services.bot.landing_url', 'https://liderra.ru');
|
||||
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => 'help/security.md', 'title' => 'Безопасность аккаунта', 'tour' => 'security',
|
||||
'topics' => 'пароль, вход в аккаунт',
|
||||
'chunk_index' => 0,
|
||||
'content' => 'Смените пароль в разделе «Профиль».',
|
||||
]);
|
||||
Http::fake([
|
||||
'llm.api.cloud.yandex.net/*' => Http::response([
|
||||
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Пароль меняется в разделе «Профиль».']]]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$answer = app(BotAnswerService::class)->answer('как сменить пароль в аккаунте?', [], fromPortal: false);
|
||||
|
||||
expect($answer->text)->not->toContain('liderra.ru/#');
|
||||
expect($answer->text)->not->toContain('?tour=');
|
||||
});
|
||||
|
||||
it('на реплику без вопроса ссылку на лендинг не даём ни гостю, ни вошедшему', function () {
|
||||
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('liderra.ru/#');
|
||||
expect($guest->text)->not->toContain('kak-eto-rabotaet');
|
||||
expect($client->text)->not->toContain('Показать на портале');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user