feat(bot): гостю - ссылка на пошаговый разбор по слагу статьи

This commit is contained in:
Дмитрий
2026-07-13 18:12:18 +03:00
parent b3982980f6
commit 1bac5d9a42
3 changed files with 73 additions and 63 deletions
+16 -28
View File
@@ -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;
}
}
}
+18
View File
@@ -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',
],
];
+39 -35
View File
@@ -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('Показать на портале');
});