diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index cf7f78fb..db6cb7d2 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -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 - */ - 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; + } } } diff --git a/app/config/guides.php b/app/config/guides.php new file mode 100644 index 00000000..2268d6b5 --- /dev/null +++ b/app/config/guides.php @@ -0,0 +1,18 @@ + [ + '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', + ], +]; diff --git a/app/tests/Feature/Bot/ChatToursTest.php b/app/tests/Feature/Bot/ChatToursTest.php index a5ff378f..321b2592 100644 --- a/app/tests/Feature/Bot/ChatToursTest.php +++ b/app/tests/Feature/Bot/ChatToursTest.php @@ -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('Показать на портале'); });