Compare commits

...

26 Commits

Author SHA1 Message Date
Дмитрий 726c682d2e docs(tours): этап 3 построен — протокол и спека синхронизированы 2026-07-03 03:35:09 +03:00
Дмитрий 7ea084d01f test(tours): frontmatter tour статей сверяется с каталогом экскурсий 2026-07-03 03:31:08 +03:00
Дмитрий 85c7c9b53c feat(tours): data-tour якоря — кнопки создания проекта и пополнения 2026-07-03 03:29:34 +03:00
Дмитрий 4ad2c065fc feat(tours): запуск экскурсии по ?tour= из ссылки бота 2026-07-03 03:26:28 +03:00
Дмитрий 4afa228f15 feat(tours): GuidedTour — обобщённый раннер с ожиданием цели 2026-07-03 03:23:59 +03:00
Дмитрий 2f9d7743ec feat(tours): каталог экскурсий — 5 стартовых сценариев 2026-07-03 03:22:38 +03:00
Дмитрий 94e5828fbc docs(tours): план этапа 3 — экскурсии «Показать на портале» (6 задач TDD)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 03:19:57 +03:00
Дмитрий 6841492226 docs(bot): спека/протокол — FTS вместо pgvector, статьи в resources/help
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:17:05 +03:00
Дмитрий edf98d9ace style(bot): pint — неиспользуемые импорты в трёх bot-тестах
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:15:14 +03:00
Дмитрий c90f721978 test(bot): бюджет скорости — p95 нашей части тракта < 500мс 2026-07-02 21:11:52 +03:00
Дмитрий 726aeb716a feat(bot): JivoBotController — webhook /api/webhook/jivo/{secret}, мгновенный ack 2026-07-02 21:09:28 +03:00
Дмитрий 7a18dae0ca feat(bot): ProcessJivoMessageJob — оркестратор, очередь bot, журнал+latency 2026-07-02 21:07:41 +03:00
Дмитрий 335bf4c3a8 feat(bot): BotAnswerService — стоп-темы, строгий промпт, tour-ссылка под флагом 2026-07-02 21:00:40 +03:00
Дмитрий e2dfd22471 feat(bot): JivoBotClient — BOT_MESSAGE/INVITE_AGENT, dev-режим без URL 2026-07-02 20:58:24 +03:00
Дмитрий edbfd3e993 feat(bot): YandexGptClient — completion Lite, таймаут 8с, null при беде 2026-07-02 20:57:03 +03:00
Дмитрий 4cab703b82 feat(bot): KnowledgeSearch — FTS russian top-N с ранжированием 2026-07-02 20:55:27 +03:00
Дмитрий 3a724fb8ef feat(bot): schedule help:rebuild-knowledge — heartbeat + Europe/Moscow (стиль соседей)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:51:04 +03:00
Дмитрий 508d8cc1d5 feat(bot): help:rebuild-knowledge — индексация статей в knowledge_chunks + schedule 04:30 2026-07-02 20:49:11 +03:00
Дмитрий b04bb4ecf3 feat(bot): HelpArticleParser — frontmatter + чанки статей инструкции 2026-07-02 20:45:41 +03:00
Дмитрий e8e7332101 docs(help): три стартовые статьи клиентской инструкции (база знаний бота) 2026-07-02 20:43:13 +03:00
Дмитрий 9f8ded5b77 test(bot): SchemaDelta ожидания v8.58 — 76 таблиц / 130 индексов (+knowledge_chunks, +bot_dialogs)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:40:52 +03:00
Дмитрий e01bcca751 feat(bot): таблица bot_dialogs — журнал диалогов бота 2026-07-02 20:37:47 +03:00
Дмитрий aa3bf3cbed feat(bot): таблица knowledge_chunks — база знаний бота (FTS russian + GIN) 2026-07-02 20:30:44 +03:00
Дмитрий e3b58f2c2c feat(bot): конфиг jivo_bot + yandexgpt (спека 2026-07-02) 2026-07-02 20:27:09 +03:00
Дмитрий f606a06155 docs(bot): план реализации ядра ИИ-бота Jivo (14 задач TDD)
По спеке 2026-07-02-jivo-ai-support-bot-design: webhook + FTS-поиск
по инструкции + YandexGPT Lite + эскалация + журнал + тест скорости.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:27:56 +03:00
Дмитрий b4ef5830e3 docs(support-bot): спека своего ИИ-бота в чате Jivo + протокол обсуждения
Дизайн согласован владельцем 02.07.2026: свой бот через Jivo Bot API,
YandexGPT Lite, база знаний = docs/help/ в репо, кнопка «Показать»
(экскурсии), скорость 2-5 сек, v1 только общие вопросы.
Словарь: +jivo/дживо/gigachat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:27:56 +03:00
50 changed files with 4419 additions and 13 deletions
+6
View File
@@ -84,6 +84,12 @@ MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_EMAIL=support@liderra.ru
JIVO_WIDGET_ID=
JIVO_BOT_WEBHOOK_SECRET=
JIVO_BOT_OUTBOUND_URL=
JIVO_BOT_TOKEN=
JIVO_BOT_TOURS_ENABLED=false
YANDEX_GPT_API_KEY=
YANDEX_GPT_FOLDER_ID=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\KnowledgeChunk;
use App\Support\Help\HelpArticleParser;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Переиндексация базы знаний бота из resources/help/*.md (спека §3).
* Полная перезаливка в транзакции: удалённые статьи исчезают, новые появляются.
* Ночной schedule 04:30 + ручной запуск при срочном обновлении инструкции.
*/
class HelpRebuildKnowledgeCommand extends Command
{
protected $signature = 'help:rebuild-knowledge';
protected $description = 'Перечитать статьи resources/help и обновить knowledge_chunks';
public function handle(HelpArticleParser $parser): int
{
$dir = resource_path('help');
$files = glob($dir.'/*.md') ?: [];
if ($files === []) {
$this->error("В {$dir} нет статей *.md — база знаний осталась прежней.");
return self::FAILURE;
}
$articles = [];
foreach ($files as $file) {
$articles[] = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
}
DB::transaction(function () use ($articles): void {
KnowledgeChunk::query()->delete();
foreach ($articles as $article) {
foreach ($article->chunks as $i => $chunk) {
KnowledgeChunk::create([
'source_path' => $article->sourcePath,
'title' => $article->title,
'tour' => $article->tour,
'topics' => $article->topics,
'chunk_index' => $i,
'content' => $chunk,
]);
}
}
});
$this->info(sprintf('Проиндексировано статей: %d.', count($articles)));
return self::SUCCESS;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\Bot\ProcessJivoMessageJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Приём событий Jivo Bot API (спека §§2,5). Образец защиты SupplierWebhookController:
* секрет в URL (≥32 симв., config services.jivo_bot.webhook_secret), hash_equals,
* несовпадение 404 (не палим endpoint). Ack мгновенный (лимит Jivo 3 сек):
* вся работа в ProcessJivoMessageJob. Обрабатываем только CLIENT_MESSAGE
* с непустым текстом; служебные события подтверждаем и игнорируем.
*/
class JivoBotController extends Controller
{
public function receive(Request $request, string $secret = ''): JsonResponse
{
$expected = (string) config('services.jivo_bot.webhook_secret');
if ($expected === '' || strlen($expected) < 32 || ! hash_equals($expected, $secret)) {
return response()->json(['message' => 'Not found.'], 404);
}
$event = (string) $request->input('event', '');
$text = trim((string) $request->input('message.text', ''));
$chatId = (string) $request->input('chat_id', '');
if ($event === 'CLIENT_MESSAGE' && $text !== '' && $chatId !== '') {
ProcessJivoMessageJob::dispatch($chatId, (string) $request->input('client_id', ''), $text);
}
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Bot;
use App\Models\BotDialog;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\JivoBotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Оркестратор ответа бота (спека §2). Очередь `bot` отдельный worker на проде,
* чтобы поток лидов не задерживал ответы чата (скорость требование №1).
* timeout 12с < 15с Jivo-страховки: не успели Jivo сам позовёт оператора.
* $tries=1: ретраить разговор бессмысленно, клиент уже у живого оператора.
*/
class ProcessJivoMessageJob implements ShouldQueue
{
use Queueable;
public int $timeout = 12;
public int $tries = 1;
public function __construct(
public readonly string $chatId,
public readonly string $clientId,
public readonly string $text,
) {
$this->onQueue('bot');
}
public function handle(): void
{
$startedAt = hrtime(true);
BotDialog::create([
'jivo_chat_id' => $this->chatId,
'direction' => 'in',
'message' => $this->text,
'created_at' => now(),
]);
$answer = app(BotAnswerService::class)->answer($this->text);
$jivo = app(JivoBotClient::class);
$jivo->sendMessage($this->chatId, $this->clientId, $answer->text);
if ($answer->escalate) {
$jivo->inviteAgent($this->chatId, $this->clientId);
}
BotDialog::create([
'jivo_chat_id' => $this->chatId,
'direction' => 'out',
'message' => $answer->text,
'matched_chunks' => $answer->matchedChunkIds,
'latency_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000),
'escalated' => $answer->escalate,
'created_at' => now(),
]);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Строка журнала диалога бота (direction: in/out). created_at only — updated_at нет. */
class BotDialog extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = ['matched_chunks' => 'array', 'escalated' => 'bool', 'created_at' => 'datetime'];
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Чанк базы знаний бота. search_tsv — generated column, в PHP не трогаем. */
class KnowledgeChunk extends Model
{
protected $guarded = [];
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
/** Итог обработки вопроса. escalate=true → после текста зовём живого оператора. @param list<int> $matchedChunkIds */
class BotAnswer
{
public function __construct(
public readonly string $text,
public readonly bool $escalate,
public readonly array $matchedChunkIds = [],
) {}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
/**
* Мозг ответа (спека §§45,7): стоп-темы эскалация до LLM; пустой поиск
* честное «не знаю»; иначе YandexGPT строго по найденным фрагментам инструкции.
* Tour-ссылка «Показать на портале» из frontmatter самой релевантной статьи,
* только под флагом tours_enabled (включается этапом 3).
*/
class BotAnswerService
{
/** Личные данные, деньги конкретного клиента, скидки, юр-темы, просьба человека. */
private const STOP_PATTERN = '/(мо[йяеи]\s+(баланс|счет|счёт|деньг|проект|заявк|сделк)|у меня (на )?(балансе|счете|счёте)|скидк|оператор|человек|менеджер|жалоб|претензи|юрист|договор|возврат денег)/iu';
private const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист — передаю ему диалог. Он ответит здесь же.';
private const UNKNOWN_TEXT = 'Честно — в моей инструкции нет ответа на этот вопрос. Передаю живому специалисту, он ответит здесь же.';
public function __construct(
private readonly KnowledgeSearch $search,
private readonly YandexGptClient $gpt,
) {}
public function answer(string $question): BotAnswer
{
if (preg_match(self::STOP_PATTERN, $question) === 1) {
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
}
$chunks = $this->search->search($question, 3);
if ($chunks === []) {
return new BotAnswer(self::UNKNOWN_TEXT, escalate: true);
}
$context = implode("\n\n---\n\n", array_map(
fn ($c) => "### {$c->title}\n{$c->content}",
$chunks
));
$system = <<<PROMPT
Ты консультант техподдержки портала Лидерра (лиды для бизнеса). Отвечай кратко
(25 предложений), простым русским языком, дружелюбно и на «вы».
СТРОГИЕ ПРАВИЛА: отвечай ТОЛЬКО по приведённым ниже фрагментам инструкции;
если ответа в них нет скажи честно «в инструкции этого нет». Ничего не выдумывай.
Не обещай скидок, цен и сроков, которых нет в фрагментах. Не отвечай на вопросы
о данных конкретного клиента (баланс, его проекты) предложи позвать специалиста.
Фрагменты инструкции:
{$context}
PROMPT;
$text = $this->gpt->complete($system, $question);
if ($text === null) {
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
}
$tour = $chunks[0]->tour;
if ($tour !== null && (bool) config('services.jivo_bot.tours_enabled')) {
$text .= "\n\n👉 Показать на портале: ".rtrim((string) config('app.url'), '/').'/?tour='.$tour;
}
return new BotAnswer($text, escalate: false, matchedChunkIds: array_map(fn ($c) => (int) $c->id, $chunks));
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Исходящие события в Jivo Bot API. outbound_url выдаёт Jivo письмом при
* подключении бота (протокол, О-2); пустой URL = dev/CI, событие только в лог.
* Формат событий Jivo Bot API: BOT_MESSAGE (текст клиенту), INVITE_AGENT
* (позвать живого оператора).
*/
class JivoBotClient
{
public function sendMessage(string $chatId, string $clientId, string $text): void
{
$this->post([
'event' => 'BOT_MESSAGE',
'id' => (string) Str::uuid(),
'chat_id' => $chatId,
'client_id' => $clientId,
'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => now()->getTimestamp()],
]);
}
public function inviteAgent(string $chatId, string $clientId): void
{
$this->post([
'event' => 'INVITE_AGENT',
'id' => (string) Str::uuid(),
'chat_id' => $chatId,
'client_id' => $clientId,
]);
}
/** @param array<string, mixed> $payload */
private function post(array $payload): void
{
$url = (string) config('services.jivo_bot.outbound_url');
if ($url === '') {
Log::info('JivoBot outbound skipped (no outbound_url)', ['event' => $payload['event']]);
return;
}
try {
Http::timeout(5)->post($url, $payload)->throw();
} catch (\Throwable $e) {
Log::warning('JivoBot outbound failure', ['event' => $payload['event'], 'error' => $e->getMessage()]);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
use App\Models\KnowledgeChunk;
use Illuminate\Support\Collection;
/**
* Поиск по базе знаний бота: PostgreSQL FTS (russian) по generated-колонке
* search_tsv (title+topics+content), ранжирование ts_rank. websearch_to_tsquery
* терпим к пользовательскому вводу (спецсимволы не ломают запрос).
* Интерфейс намеренно узкий замена на pgvector позже не тронет вызывающих.
*
* @return list<KnowledgeChunk>
*/
class KnowledgeSearch
{
public function search(string $question, int $limit = 3): array
{
$question = trim($question);
if ($question === '') {
return [];
}
/** @var Collection<int, KnowledgeChunk> $hits */
$hits = KnowledgeChunk::query()
->selectRaw(
"knowledge_chunks.*, ts_rank(search_tsv, websearch_to_tsquery('russian', ?)) AS rank",
[$question]
)
->whereRaw("search_tsv @@ websearch_to_tsquery('russian', ?)", [$question])
->orderByDesc('rank')
->limit($limit)
->get();
return $hits->all();
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Services\Bot;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* YandexGPT Lite (Yandex Cloud Foundation Models) мозг бота (протокол, решение 8).
* Возвращает null при любой беде (нет ключа, таймаут, 5xx) решение об эскалации
* принимает вызывающий. Таймаут 8 сек бюджет скорости из спеки §6.
*/
class YandexGptClient
{
public function complete(string $systemPrompt, string $userText): ?string
{
$cfg = (array) config('services.yandexgpt');
if (($cfg['api_key'] ?? '') === '' || ($cfg['folder_id'] ?? '') === '') {
return null;
}
try {
$response = Http::timeout((int) ($cfg['timeout_seconds'] ?? 8))
->withHeaders(['Authorization' => 'Api-Key '.$cfg['api_key']])
->post((string) $cfg['endpoint'], [
'modelUri' => sprintf('gpt://%s/%s', $cfg['folder_id'], $cfg['model']),
'completionOptions' => ['stream' => false, 'temperature' => 0.2, 'maxTokens' => 500],
'messages' => [
['role' => 'system', 'text' => $systemPrompt],
['role' => 'user', 'text' => $userText],
],
]);
if (! $response->ok()) {
Log::warning('YandexGPT non-OK', ['status' => $response->status()]);
return null;
}
$text = $response->json('result.alternatives.0.message.text');
return is_string($text) && $text !== '' ? $text : null;
} catch (\Throwable $e) {
Log::warning('YandexGPT failure', ['error' => $e->getMessage()]);
return null;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Support\Help;
/** Разобранная статья инструкции. @param list<string> $chunks */
class HelpArticle
{
public function __construct(
public readonly string $sourcePath,
public readonly string $title,
public readonly ?string $tour,
public readonly string $topics,
public readonly array $chunks,
) {}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support\Help;
use InvalidArgumentException;
/**
* Разбор статьи клиентской инструкции (resources/help/*.md):
* frontmatter (title/tour/topics, простые key: value между «---») + тело,
* порезанное на чанки ~1200 симв. по границам абзацев (для FTS-поиска).
* Без YAML-зависимости формат статей намеренно плоский.
*/
class HelpArticleParser
{
private const CHUNK_TARGET_CHARS = 1200;
public function parse(string $sourcePath, string $markdown): HelpArticle
{
if (! preg_match('/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/su', trim($markdown), $m)) {
throw new InvalidArgumentException("Статья {$sourcePath}: нет frontmatter (--- title/topics ---).");
}
$meta = [];
foreach (preg_split('/\r?\n/', $m[1]) as $line) {
if (preg_match('/^(\w+):\s*(.*)$/u', trim($line), $kv)) {
$meta[$kv[1]] = trim($kv[2]);
}
}
if (($meta['title'] ?? '') === '') {
throw new InvalidArgumentException("Статья {$sourcePath}: пустой title во frontmatter.");
}
$paragraphs = array_values(array_filter(
array_map('trim', preg_split('/\r?\n\r?\n+/', trim($m[2]))),
fn (string $p) => $p !== ''
));
$chunks = [];
$current = '';
foreach ($paragraphs as $p) {
if ($current !== '' && mb_strlen($current) + mb_strlen($p) > self::CHUNK_TARGET_CHARS) {
$chunks[] = $current;
$current = $p;
} else {
$current = $current === '' ? $p : $current."\n\n".$p;
}
}
if ($current !== '') {
$chunks[] = $current;
}
return new HelpArticle(
sourcePath: $sourcePath,
title: $meta['title'],
tour: ($meta['tour'] ?? '') !== '' ? $meta['tour'] : null,
topics: $meta['topics'] ?? '',
chunks: $chunks,
);
}
}
+19
View File
@@ -75,6 +75,25 @@ return [
'widget_id' => env('JIVO_WIDGET_ID'),
],
// ИИ-бот техподдержки в чате Jivo (спека 2026-07-02-jivo-ai-support-bot-design).
// webhook_secret — входящий секрет в URL (≥32 симв., по образцу supplier webhook).
// outbound_url/token — выдаёт Jivo письмом при подключении Bot API; пусто → отправка
// событий отключена (dev/CI), бот пишет только в журнал.
'jivo_bot' => [
'webhook_secret' => env('JIVO_BOT_WEBHOOK_SECRET', ''),
'outbound_url' => env('JIVO_BOT_OUTBOUND_URL', ''),
'token' => env('JIVO_BOT_TOKEN', ''),
'tours_enabled' => env('JIVO_BOT_TOURS_ENABLED', false),
],
// YandexGPT Lite (Yandex Cloud Foundation Models) — мозг бота (решение 8 протокола).
'yandexgpt' => [
'api_key' => env('YANDEX_GPT_API_KEY', ''),
'folder_id' => env('YANDEX_GPT_FOLDER_ID', ''),
'model' => env('YANDEX_GPT_MODEL', 'yandexgpt-lite/latest'),
'endpoint' => env('YANDEX_GPT_ENDPOINT', 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion'),
'timeout_seconds' => 8,
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* База знаний ИИ-бота (спека 2026-07-02-jivo-ai-support-bot-design §3).
* Глобальная таблица (НЕ tenant-scoped): только публичные статьи инструкции,
* данных клиентов здесь нет по определению RLS не требуется.
* search_tsv generated column (russian) + GIN: поиск за миллисекунды.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS knowledge_chunks (
id BIGSERIAL PRIMARY KEY,
source_path VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
tour VARCHAR(100),
topics TEXT NOT NULL DEFAULT '',
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
search_tsv tsvector GENERATED ALWAYS AS (
to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(topics, '') || ' ' || coalesce(content, ''))
) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_knowledge_chunks_source_chunk UNIQUE (source_path, chunk_index)
)
SQL);
DB::statement('CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_search ON knowledge_chunks USING GIN (search_tsv)');
}
public function down(): void
{
DB::statement('DROP TABLE IF EXISTS knowledge_chunks');
}
};
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Журнал диалогов ИИ-бота (спека §5). Глобальная (НЕ tenant-scoped) в v1:
* диалоги Jivo анонимны до этапа личных ответов; ПДн клиентов не пишем.
* direction: in = сообщение клиента, out = ответ бота.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS bot_dialogs (
id BIGSERIAL PRIMARY KEY,
jivo_chat_id VARCHAR(64) NOT NULL,
direction VARCHAR(3) NOT NULL CHECK (direction IN ('in', 'out')),
message TEXT NOT NULL,
matched_chunks JSONB,
latency_ms INTEGER,
escalated BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
SQL);
DB::statement('CREATE INDEX IF NOT EXISTS idx_bot_dialogs_chat ON bot_dialogs (jivo_chat_id, created_at)');
}
public function down(): void
{
DB::statement('DROP TABLE IF EXISTS bot_dialogs');
}
};
+16
View File
@@ -0,0 +1,16 @@
---
title: Что такое проект
tour: create-project
topics: создать проект, заявка на лиды, источник, сайт конкурента, лимит заявок, новый проект
---
Проект — это ваша заявка на поток клиентов. Вы указываете источник (например, сайт,
похожий на ваш бизнес) и сколько заявок в день хотите получать — а система начинает
присылать вам заявки с контактами.
Как создать: раздел «Проекты» → кнопка «Создать проект». Понадобится указать название,
источник и дневной лимит заявок. После создания проект начинает работать не сразу —
обычно в течение суток.
Заявки из проекта появляются в разделе «Сделки» — с телефоном, источником и статусом.
Проект можно поставить на паузу или изменить лимит в любой момент.
+12
View File
@@ -0,0 +1,12 @@
---
title: Как пополнить баланс
tour: top-up-balance
topics: пополнить, закинуть деньги, оплата, счёт, платёж, банковская карта, безнал, пополнение баланса
---
Пополнить баланс: раздел «Биллинг» → кнопка «Пополнить». Доступна оплата по счёту
для юридических лиц и ИП: система выставит PDF-счёт, после оплаты деньги зачислятся,
а акт придёт на почту.
Зачисление по счёту происходит после подтверждения оплаты. Баланс и историю всех
операций видно в разделе «Биллинг».
+14
View File
@@ -0,0 +1,14 @@
---
title: Тарифы и списания
tour: tariffs
topics: сколько стоит, цена заявки, цена лида, тариф, списание, деньги, стоимость, оплата за лид
---
Вы платите только за полученные заявки — абонентской платы нет. Деньги списываются
с баланса за каждую доставленную заявку по вашей тарифной ступени.
Тарифная ступень зависит от объёма: чем больше заявок в месяц, тем дешевле каждая.
Актуальные цены — раздел «Биллинг» → «Тарифы».
Если на балансе не хватает денег на очередную заявку, проекты автоматически встают
на паузу — ничего не сгорает, после пополнения работа продолжается.
@@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* GuidedTour — обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
* действия клиента (открыл диалог) — меряем с ретраем каждые 300мс до 15с.
* Разметка/стили — по образцу WelcomeTour (единый вид подсказок).
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { TourStep } from '../../tours/catalog';
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
const emit = defineEmits<{ finish: [] }>();
const RETRY_MS = 300;
const RETRY_MAX = 50; // 15 сек
const stepIndex = ref(0);
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
let retryTimer: ReturnType<typeof setInterval> | null = null;
const currentStep = computed(() => props.steps[stepIndex.value]);
const isLast = computed(() => stepIndex.value === props.steps.length - 1);
function stopRetry(): void {
if (retryTimer !== null) {
clearInterval(retryTimer);
retryTimer = null;
}
}
function measure(): void {
stopRetry();
targetRect.value = null;
const sel = currentStep.value?.target;
if (!sel) return;
let attempts = 0;
const tryMeasure = (): void => {
const el = document.querySelector(sel);
if (el) {
const r = el.getBoundingClientRect();
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
stopRetry();
return;
}
attempts += 1;
if (attempts >= RETRY_MAX) stopRetry();
};
tryMeasure();
if (targetRect.value === null) {
retryTimer = setInterval(tryMeasure, RETRY_MS);
}
}
const highlightStyle = computed(() => {
const r = targetRect.value;
if (!r) return { display: 'none' };
const pad = 6;
return {
top: `${r.top - pad}px`,
left: `${r.left - pad}px`,
width: `${r.width + pad * 2}px`,
height: `${r.height + pad * 2}px`,
};
});
const tooltipStyle = computed(() => {
const r = targetRect.value;
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
return { top: `${Math.max(12, r.top)}px`, left: `${r.left + r.width + 16}px` };
});
function next(): void {
if (isLast.value) {
finish();
return;
}
stepIndex.value += 1;
measure();
}
function finish(): void {
stopRetry();
emit('finish');
}
function onResize(): void {
if (props.active) measure();
}
watch(
() => props.active,
(on) => {
if (on) {
stepIndex.value = 0;
requestAnimationFrame(() => measure());
window.addEventListener('resize', onResize);
} else {
stopRetry();
window.removeEventListener('resize', onResize);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopRetry();
window.removeEventListener('resize', onResize);
});
defineExpose({ stepIndex, targetRect, next, finish });
</script>
<template>
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour">
<div class="guided-tour__backdrop" />
<div v-if="targetRect" class="guided-tour__highlight" :style="highlightStyle" />
<div class="guided-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
<div class="guided-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
<h3 class="guided-tour__title">{{ currentStep.title }}</h3>
<p class="guided-tour__text">{{ currentStep.text }}</p>
<div class="guided-tour__actions">
<v-btn variant="text" size="small" data-testid="tour-skip" @click="finish">Закрыть</v-btn>
<v-btn color="primary" variant="flat" size="small" data-testid="tour-next" @click="next">
{{ isLast ? 'Готово' : 'Далее' }}
</v-btn>
</div>
</div>
</div>
</template>
<style scoped>
.guided-tour {
position: fixed;
inset: 0;
z-index: 3000;
pointer-events: none;
}
.guided-tour__backdrop {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.55);
pointer-events: auto;
}
.guided-tour__highlight {
position: absolute;
border: 2px solid var(--liderra-teal, #0f6e56);
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(1, 32, 25, 0.55);
transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.guided-tour__card {
position: absolute;
width: 300px;
max-width: calc(100vw - 24px);
background: #fff;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
pointer-events: auto;
}
.guided-tour__step {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6b7470;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.guided-tour__title {
font-size: 16px;
font-weight: 600;
margin: 4px 0 6px;
color: #081319;
}
.guided-tour__text {
font-size: 13px;
line-height: 1.45;
color: #3a423f;
margin: 0 0 14px;
}
.guided-tour__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,35 @@
/**
* Запуск экскурсии по ссылке из чата бота: /?tour=<имя> (спека ИИ-бота §4).
* Невошедшего роутер сам отправит на /login с redirect=fullPath — query
* переживает вход (router/index.ts beforeEach), поэтому отдельной логики
* логина здесь нет. Мусорное имя — молча чистим query (не пугаем клиента).
*/
import { ref, type Ref } from 'vue';
import type { Router } from 'vue-router';
import { findTour, type TourScenario } from '../tours/catalog';
interface RouteLike {
query: Record<string, unknown>;
}
export function useTourLauncher(route: Ref<RouteLike>, router: Router) {
const activeTour = ref<TourScenario | null>(null);
async function checkQuery(): Promise<void> {
const name = typeof route.value.query.tour === 'string' ? route.value.query.tour : '';
if (name === '') return;
const tour = findTour(name);
if (tour === null) {
await router.replace({ query: { ...route.value.query, tour: undefined } });
return;
}
activeTour.value = tour;
await router.push({ path: tour.steps[0].route, query: {} });
}
function finishTour(): void {
activeTour.value = null;
}
return { activeTour, checkQuery, finishTour };
}
+21 -2
View File
@@ -9,8 +9,8 @@
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
*/
import { computed, onMounted, ref } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useNotificationsStore } from '../stores/notifications';
import { useTenantStore } from '../stores/tenantStore';
@@ -24,12 +24,17 @@ import JivoWidget from '../components/support/JivoWidget.vue';
import BalanceFrozenBanner from '../components/billing/BalanceFrozenBanner.vue';
import ImpersonationSessionBanner from '../components/admin/ImpersonationSessionBanner.vue';
import WelcomeTour from '../components/layout/WelcomeTour.vue';
import GuidedTour from '../components/layout/GuidedTour.vue';
import { useTourLauncher } from '../composables/useTourLauncher';
const auth = useAuthStore();
const notifications = useNotificationsStore();
const tenant = useTenantStore();
const route = useRoute();
const router = useRouter();
const tourLauncher = useTourLauncher(computed(() => route), router);
const drawerOpen = ref(true);
// Тот же навигационный pool что в AppSidebar — для crumb-resolution в topbar
@@ -65,7 +70,15 @@ async function loadBalanceStatus(): Promise<void> {
onMounted(() => {
void loadNotifications();
void loadBalanceStatus();
void tourLauncher.checkQuery();
});
watch(
() => route.query.tour,
() => {
void tourLauncher.checkQuery();
},
);
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
</script>
@@ -92,6 +105,12 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
<CommandPalette />
<JivoWidget />
<WelcomeTour />
<GuidedTour
v-if="tourLauncher.activeTour.value"
:steps="tourLauncher.activeTour.value.steps"
:active="true"
@finish="tourLauncher.finishTour()"
/>
</v-app>
</template>
+95
View File
@@ -0,0 +1,95 @@
/**
* Каталог экскурсий «Показать на портале» (спека ИИ-бота §4).
* ИИ шаги НЕ сочиняет — только выбирает готовый сценарий по имени
* (frontmatter `tour:` статьи resources/help). Селекторы целей — существующие
* data-tour (sidebar: nav-*) и data-testid; target может появиться ПОСЛЕ
* действия клиента (открыл диалог) — раннер умеет ждать (см. GuidedTour).
*/
export interface TourStep {
/** Роут, на котором живёт цель шага; раннер переходит туда сам. */
route: string;
/** CSS-селектор цели подсветки. */
target: string;
title: string;
text: string;
}
export interface TourScenario {
name: string;
steps: TourStep[];
}
export const TOURS: TourScenario[] = [
{
name: 'create-project',
steps: [
{
route: '/projects',
target: '[data-tour="nav-projects"]',
title: 'Раздел «Проекты»',
text: 'Здесь живут все ваши проекты — заявки на поток клиентов.',
},
{
route: '/projects',
target: '[data-tour="projects-create"]',
title: 'Создать проект',
text: 'Нажмите эту кнопку — откроется форма нового проекта. Понадобятся название, источник и дневной лимит заявок.',
},
],
},
{
name: 'top-up-balance',
steps: [
{
route: '/billing',
target: '[data-tour="nav-billing"]',
title: 'Раздел «Биллинг»',
text: 'Баланс, история операций и пополнение — всё здесь.',
},
{
route: '/billing',
target: '[data-tour="billing-topup"]',
title: 'Пополнить баланс',
text: 'Нажмите, чтобы выставить счёт на пополнение. После оплаты деньги зачислятся автоматически.',
},
],
},
{
name: 'tariffs',
steps: [
{
route: '/billing',
target: '[data-tour="nav-billing"]',
title: 'Тарифы — в «Биллинге»',
text: 'Вы платите только за полученные заявки. Актуальные цены и ваша тарифная ступень — в этом разделе.',
},
],
},
{
name: 'change-source',
steps: [
{
route: '/projects',
target: '[data-tour="nav-projects"]',
title: 'Смена источника — в «Проектах»',
text: 'Откройте нужный проект — в его настройках можно сменить источник без потери заявок.',
},
],
},
{
name: 'notifications',
steps: [
{
route: '/settings',
target: '[data-tour="nav-settings"]',
title: 'Уведомления — в «Настройках»',
text: 'Здесь включаются письма о новых заявках и другие уведомления.',
},
],
},
];
export function findTour(name: string): TourScenario | null {
if (name === '') return null;
return TOURS.find((t) => t.name === name) ?? null;
}
+1 -1
View File
@@ -85,7 +85,7 @@ defineExpose({ loadWallet, wallet, topupOpen });
>
</div>
</div>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" data-tour="billing-topup" @click="topupOpen = true"
>Пополнить баланс</v-btn
>
</header>
+1 -1
View File
@@ -2,7 +2,7 @@
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
<div class="d-flex justify-space-between align-center mb-4">
<h1 class="text-h4">Проекты</h1>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">Создать проект</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" data-tour="projects-create" @click="openCreate">Создать проект</v-btn>
</div>
<v-alert
+11 -1
View File
@@ -4,6 +4,7 @@ use App\Jobs\SendNewLeadsDigestJob;
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\FlushDeferredOnlineSyncJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Services\SchedulerHeartbeatTracker;
@@ -43,7 +44,7 @@ Schedule::command('projects:reset-delivered-today')
// Task 4.2: досыл отложенной онлайн-очереди в 00:05 МСК (после сброса счётчиков в 00:00,
// вне окна 18:00→00:00 — отложенные правки уходят поставщику немедленно).
Schedule::job(new \App\Jobs\Supplier\FlushDeferredOnlineSyncJob)
Schedule::job(new FlushDeferredOnlineSyncJob)
->dailyAt('00:05')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', true, null, null))
@@ -185,3 +186,12 @@ Schedule::command('scheduler:check-heartbeats')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
// База знаний ИИ-бота: ночная переиндексация статей resources/help
// (спека 2026-07-02-jivo-ai-support-bot-design §3). Изменил статью — ночью бот знает.
Schedule::command('help:rebuild-knowledge')
->dailyAt('04:30')
->timezone('Europe/Moscow')
->onOneServer()
->onSuccess(fn () => $hb->recordRunResult('help:rebuild-knowledge', true, null, null))
->onFailure(fn () => $hb->recordRunResult('help:rebuild-knowledge', false, 'Command failed', null));
+6
View File
@@ -336,6 +336,12 @@ Route::post('/api/webhook/supplier', 'App\Http\Controllers\Api\SupplierWebhookCo
Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive')
->where('secret', '[A-Za-z0-9_\-]+');
// ИИ-бот техподдержки: события Jivo Bot API (CLIENT_MESSAGE и служебные).
// Защита — секрет в URL по образцу supplier-webhook; ack мгновенный, работа в джобе
// (спека docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md §5).
Route::post('/api/webhook/jivo/{secret}', 'App\Http\Controllers\Api\JivoBotController@receive')
->where('secret', '[A-Za-z0-9_\-]+');
// Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt.
// Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7.
Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive');
@@ -0,0 +1,85 @@
<?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);
beforeEach(function () {
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,
]);
KnowledgeChunk::create([
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => 'create-project',
'topics' => 'создать проект', 'chunk_index' => 0,
'content' => 'Проект — это заявка на поток клиентов.',
]);
});
it('стоп-тема (мой баланс) → эскалация без похода в LLM', function () {
Http::fake();
$answer = app(BotAnswerService::class)->answer('какой у меня баланс?');
expect($answer->escalate)->toBeTrue()
->and($answer->text)->toContain('специалисту');
Http::assertNothingSent();
});
it('просьба позвать человека → эскалация', function () {
Http::fake();
expect(app(BotAnswerService::class)->answer('позовите оператора')->escalate)->toBeTrue();
});
it('вопрос не по базе (пустой поиск) → честное «не знаю» + эскалация', function () {
Http::fake();
$answer = app(BotAnswerService::class)->answer('какая погода в москве');
expect($answer->escalate)->toBeTrue();
Http::assertNothingSent();
});
it('обычный вопрос → ответ LLM по контексту, без эскалации', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это заявка на поток клиентов.']]]],
]),
]);
$answer = app(BotAnswerService::class)->answer('что такое проект?');
expect($answer->escalate)->toBeFalse()
->and($answer->text)->toContain('Проект')
->and($answer->matchedChunkIds)->not->toBeEmpty();
});
it('tour-ссылка добавляется только при включённом tours_enabled', function () {
config()->set('services.jivo_bot.tours_enabled', true);
config()->set('app.url', 'https://liderra.ru');
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
]),
]);
$withTours = app(BotAnswerService::class)->answer('что такое проект?');
expect($withTours->text)->toContain('https://liderra.ru/?tour=create-project');
config()->set('services.jivo_bot.tours_enabled', false);
$without = app(BotAnswerService::class)->answer('что такое проект?');
expect($without->text)->not->toContain('?tour=');
});
it('LLM недоступен → эскалация', function () {
Http::fake(['llm.api.cloud.yandex.net/*' => Http::response('err', 500)]);
expect(app(BotAnswerService::class)->answer('что такое проект?')->escalate)->toBeTrue();
});
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('bot_dialogs принимает запись входа и выхода', function () {
DB::table('bot_dialogs')->insert([
'jivo_chat_id' => 'chat-1',
'direction' => 'in',
'message' => 'что такое проект?',
'created_at' => now(),
]);
DB::table('bot_dialogs')->insert([
'jivo_chat_id' => 'chat-1',
'direction' => 'out',
'message' => 'Проект — это…',
'matched_chunks' => json_encode([1, 2]),
'latency_ms' => 2100,
'escalated' => false,
'created_at' => now(),
]);
expect(DB::table('bot_dialogs')->where('jivo_chat_id', 'chat-1')->count())->toBe(2);
});
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessJivoMessageJob;
use App\Models\BotDialog;
use App\Models\KnowledgeChunk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('наша часть тракта (без сети) укладывается в 500 мс на вопрос', function () {
config()->set('services.jivo_bot.outbound_url', ''); // исходящие в лог
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' => 'Ответ.']]]],
]),
]);
for ($i = 0; $i < 20; $i++) {
KnowledgeChunk::create([
'source_path' => "help/a{$i}.md", 'title' => "Статья {$i}", 'tour' => null,
'topics' => 'проект, баланс, тариф', 'chunk_index' => 0,
'content' => str_repeat("Текст про проект и баланс номер {$i}. ", 30),
]);
}
$latencies = [];
for ($i = 0; $i < 10; $i++) {
(new ProcessJivoMessageJob("chat-{$i}", 'c', 'что такое проект?'))->handle();
$latencies[] = (int) BotDialog::where('jivo_chat_id', "chat-{$i}")
->where('direction', 'out')->value('latency_ms');
}
sort($latencies);
$p95 = $latencies[(int) floor(count($latencies) * 0.95) - 1] ?? end($latencies);
// Бюджет спеки §6: поиск ≤300мс + сборка/журнал ≤200мс. LLM (до 3с) и сеть
// Jivo (до 0.5с) — вне нашего кода, замоканы; живой p95 — на приёмке.
expect($p95)->toBeLessThan(500);
});
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('перечитывает resources/help и заполняет knowledge_chunks с нуля', function () {
// Мусорная строка от прошлой индексации — должна исчезнуть (полная перезаливка).
DB::table('knowledge_chunks')->insert([
'source_path' => 'help/deleted-article.md', 'title' => 'Старая', 'topics' => '',
'chunk_index' => 0, 'content' => 'мусор', 'created_at' => now(), 'updated_at' => now(),
]);
$this->artisan('help:rebuild-knowledge')->assertExitCode(0);
expect(DB::table('knowledge_chunks')->where('source_path', 'help/deleted-article.md')->count())->toBe(0)
->and(DB::table('knowledge_chunks')->where('title', 'Что такое проект')->count())->toBeGreaterThan(0)
->and(DB::table('knowledge_chunks')->where('title', 'Тарифы и списания')->count())->toBeGreaterThan(0);
});
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Support\Help\HelpArticleParser;
it('каждый tour из статей resources/help существует в каталоге экскурсий', function () {
$catalog = (string) file_get_contents(resource_path('js/tours/catalog.ts'));
preg_match_all("/name: '([a-z0-9\\-]+)'/", $catalog, $m);
$known = $m[1];
expect($known)->not->toBeEmpty();
$parser = new HelpArticleParser;
foreach (glob(resource_path('help').'/*.md') ?: [] as $file) {
$article = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
if ($article->tour !== null) {
expect($known)->toContain($article->tour);
}
}
});
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessJivoMessageJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
const JIVO_SECRET = 'test-secret-0123456789abcdef0123456789abcdef';
beforeEach(function () {
config()->set('services.jivo_bot.webhook_secret', JIVO_SECRET);
});
function jivoPayload(string $text = 'что такое проект?'): array
{
return [
'event' => 'CLIENT_MESSAGE',
'id' => 'evt-1',
'chat_id' => 'chat-1',
'client_id' => 'client-1',
'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => 1780000000],
];
}
it('валидный секрет + CLIENT_MESSAGE → 200 и джоба в очереди bot', function () {
Queue::fake();
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, jivoPayload())->assertOk();
Queue::assertPushedOn('bot', ProcessJivoMessageJob::class, function (ProcessJivoMessageJob $job) {
return $job->chatId === 'chat-1' && $job->text === 'что такое проект?';
});
});
it('неверный секрет → 404 без джобы', function () {
Queue::fake();
$this->postJson('/api/webhook/jivo/wrong-secret', jivoPayload())->assertNotFound();
Queue::assertNothingPushed();
});
it('секрет не настроен (пустой конфиг) → 404 даже с пустым секретом в URL', function () {
config()->set('services.jivo_bot.webhook_secret', '');
Queue::fake();
$this->postJson('/api/webhook/jivo/anything', jivoPayload())->assertNotFound();
Queue::assertNothingPushed();
});
it('не-CLIENT_MESSAGE (служебное событие) → 200 без джобы', function () {
Queue::fake();
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, ['event' => 'AGENT_JOINED', 'chat_id' => 'c'])
->assertOk();
Queue::assertNothingPushed();
});
it('CLIENT_MESSAGE без текста → 200 без джобы', function () {
Queue::fake();
$payload = jivoPayload();
$payload['message']['text'] = '';
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, $payload)->assertOk();
Queue::assertNothingPushed();
});
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('knowledge_chunks существует и ищется полнотекстово по-русски', function () {
DB::table('knowledge_chunks')->insert([
'source_path' => 'help/project.md',
'title' => 'Что такое проект',
'tour' => 'create-project',
'topics' => 'заявка на лиды, создать проект, источник',
'chunk_index' => 0,
'content' => 'Проект — это заявка на поток лидов с выбранного источника.',
'created_at' => now(),
'updated_at' => now(),
]);
$found = DB::select(
"SELECT id, title FROM knowledge_chunks
WHERE search_tsv @@ websearch_to_tsquery('russian', ?)",
['что такое проект']
);
expect($found)->toHaveCount(1)
->and($found[0]->title)->toBe('Что такое проект');
});
@@ -0,0 +1,44 @@
<?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 () {
expect(app(KnowledgeSearch::class)->search('какая погода в москве', 3))->toBeEmpty();
});
it('не падает на спецсимволах в вопросе', function () {
expect(app(KnowledgeSearch::class)->search('проект & | ! ( )', 3))->toBeArray();
});
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Jobs\Bot\ProcessJivoMessageJob;
use App\Models\BotDialog;
use App\Models\KnowledgeChunk;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t');
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,
]);
KnowledgeChunk::create([
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => null,
'topics' => 'создать проект', 'chunk_index' => 0,
'content' => 'Проект — это заявка на поток клиентов.',
]);
});
it('happy path: ответ уходит в Jivo, журнал пишет in+out с latency', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
]),
'bot.jivosite.com/*' => Http::response(['ok' => true]),
]);
(new ProcessJivoMessageJob('chat-1', 'client-1', 'что такое проект?'))->handle();
Http::assertSent(fn ($r) => str_contains($r->url(), 'bot.jivosite.com') && $r['event'] === 'BOT_MESSAGE');
$out = BotDialog::where('direction', 'out')->firstOrFail();
expect(BotDialog::where('direction', 'in')->count())->toBe(1)
->and($out->latency_ms)->toBeGreaterThanOrEqual(0)
->and($out->escalated)->toBeFalse()
->and($out->matched_chunks)->not->toBeNull();
});
it('эскалация: BOT_MESSAGE-прощание + INVITE_AGENT, журнал escalated=true', function () {
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
(new ProcessJivoMessageJob('chat-2', 'client-2', 'какой у меня баланс?'))->handle();
Http::assertSent(fn ($r) => ($r['event'] ?? '') === 'INVITE_AGENT');
expect(BotDialog::where('direction', 'out')->firstOrFail()->escalated)->toBeTrue();
});
it('джоба объявлена с queue=bot и timeout ≤ 12 сек', function () {
$job = new ProcessJivoMessageJob('c', 'c', 'q');
expect($job->queue)->toBe('bot')
->and($job->timeout)->toBeLessThanOrEqual(12);
});
@@ -59,7 +59,7 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS policies', function () {
it('schema.sql v8.58 has correct metrics — 76 base tables, 130 indexes, 44 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик.
@@ -76,8 +76,9 @@ it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS
// project_routing_snapshots, tenant_requisites, support_requests и др.).
// v8.54 (Эпик 4 online-defer): +1 таблица supplier_deferred_sync (SaaS-level, PK неявный, +0 явных индексов).
// v8.55 (Эпик 5 отчёт заливки): +1 таблица supplier_sync_runs + 1 индекс idx_supplier_sync_runs_created.
// Статический парс db/schema.sql после v8.54/v8.55: 74 base tables, 128 индексов, 44 RLS-политики.
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф (заявляет 79 таблиц/124 индекса) —
// v8.58 (ИИ-бот Jivo): +2 таблицы knowledge_chunks (база знаний, GIN search_tsv) и bot_dialogs
// (журнал диалогов) + 2 индекса. Статический парс: 76 base tables, 130 индексов, 44 RLS-политики.
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф —
// это отдельный canon-sync, не предмет этого теста; тест сверяет фактический парс ФАЙЛА.
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
@@ -88,10 +89,10 @@ it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
$baseTables = $createTables - $partitionOf;
expect($baseTables)->toBe(74);
expect($baseTables)->toBe(76);
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(128); // v8.55 static parse
expect($createIndexes)->toBe(130); // v8.58 static parse
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(44); // v8.52 static parse
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue';
import type { TourStep } from '../../resources/js/tours/catalog';
const steps: TourStep[] = [
{ route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
];
function mountTour() {
return mount(GuidedTour, {
props: { steps, active: true },
global: { stubs: { 'v-btn': { template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
});
}
describe('GuidedTour', () => {
it('показывает первый шаг и счётчик', () => {
const w = mountTour();
expect(w.text()).toContain('Шаг 1');
expect(w.text()).toContain('1 из 2');
});
it('Далее ведёт по шагам, на последнем — Готово и finish', async () => {
const w = mountTour();
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.text()).toContain('Шаг 2');
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('Пропустить завершает тур сразу', async () => {
const w = mountTour();
await w.find('[data-testid="tour-skip"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('цель не найдена → карточка по центру (targetRect null), без падения', () => {
const w = mountTour();
expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true);
});
it('ретрай измерения: цель появляется позже — подсветка находит её', async () => {
vi.useFakeTimers();
const w = mountTour();
const el = document.createElement('div');
el.setAttribute('data-tour', 'a');
document.body.appendChild(el);
await vi.advanceTimersByTimeAsync(1000);
expect((w.vm as any).targetRect).not.toBeNull();
el.remove();
vi.useRealTimers();
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { TOURS, findTour, type TourScenario } from '../../resources/js/tours/catalog';
describe('каталог экскурсий', () => {
it('содержит 5 стартовых сценариев с уникальными именами', () => {
const names = TOURS.map((t: TourScenario) => t.name);
expect(names).toEqual([...new Set(names)]);
for (const required of ['create-project', 'top-up-balance', 'tariffs', 'change-source', 'notifications']) {
expect(names).toContain(required);
}
});
it('каждый шаг имеет route, target, title и text', () => {
for (const tour of TOURS) {
expect(tour.steps.length).toBeGreaterThan(0);
for (const s of tour.steps) {
expect(s.route.startsWith('/')).toBe(true);
expect(s.target).toBeTruthy();
expect(s.title).toBeTruthy();
expect(s.text).toBeTruthy();
}
}
});
it('findTour находит по имени и отдаёт null на мусор', () => {
expect(findTour('create-project')?.name).toBe('create-project');
expect(findTour('no-such-tour')).toBeNull();
expect(findTour('')).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useTourLauncher } from '../../resources/js/composables/useTourLauncher';
function makeRouterMocks(query: Record<string, string>) {
const route = ref({ query, fullPath: '/x' });
const router = { push: vi.fn().mockResolvedValue(undefined), replace: vi.fn().mockResolvedValue(undefined) };
return { route, router };
}
describe('useTourLauncher', () => {
it('валидный ?tour= → активирует сценарий и ведёт на роут первого шага', async () => {
const { route, router } = makeRouterMocks({ tour: 'create-project' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value?.name).toBe('create-project');
expect(router.push).toHaveBeenCalledWith({ path: '/projects', query: {} });
});
it('мусорный ?tour= → игнор без падения, query чистится', async () => {
const { route, router } = makeRouterMocks({ tour: 'no-such' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value).toBeNull();
expect(router.replace).toHaveBeenCalled();
});
it('без ?tour= — ничего не делает', async () => {
const { route, router } = makeRouterMocks({});
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value).toBeNull();
expect(router.push).not.toHaveBeenCalled();
});
it('finishTour гасит активный тур', async () => {
const { route, router } = makeRouterMocks({ tour: 'tariffs' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
l.finishTour();
expect(l.activeTour.value).toBeNull();
});
});
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Support\Help\HelpArticleParser;
use Tests\TestCase;
uses(TestCase::class);
it('парсит frontmatter и режет тело на чанки по абзацам', function () {
$md = <<<'MD'
---
title: Что такое проект
tour: create-project
topics: создать проект, заявка на лиды
---
Первый абзац про проект.
Второй абзац про создание.
MD;
$article = (new HelpArticleParser)->parse('help/x.md', $md);
expect($article->title)->toBe('Что такое проект')
->and($article->tour)->toBe('create-project')
->and($article->topics)->toBe('создать проект, заявка на лиды')
->and($article->chunks)->toHaveCount(1)
->and($article->chunks[0])->toContain('Первый абзац');
});
it('без frontmatter кидает понятную ошибку', function () {
expect(fn () => (new HelpArticleParser)->parse('help/bad.md', 'просто текст'))
->toThrow(InvalidArgumentException::class);
});
it('длинное тело режет на несколько чанков ~1200 символов по границам абзацев', function () {
$body = implode("\n\n", array_fill(0, 10, str_repeat('а', 300)));
$md = "---\ntitle: Т\ntopics: т\n---\n\n".$body;
$article = (new HelpArticleParser)->parse('help/long.md', $md);
expect(count($article->chunks))->toBeGreaterThan(1);
foreach ($article->chunks as $chunk) {
expect(mb_strlen($chunk))->toBeLessThanOrEqual(1500);
}
});
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Services\Bot\JivoBotClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/prov-1/tok-1');
});
it('BOT_MESSAGE уходит с chat_id/client_id и текстом', function () {
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'Проект — это…');
Http::assertSent(function ($request) {
return $request->url() === 'https://bot.jivosite.com/webhooks/prov-1/tok-1'
&& $request['event'] === 'BOT_MESSAGE'
&& $request['chat_id'] === 'chat-1'
&& $request['client_id'] === 'client-1'
&& $request['message']['type'] === 'TEXT'
&& $request['message']['text'] === 'Проект — это…';
});
});
it('INVITE_AGENT уходит без message', function () {
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
app(JivoBotClient::class)->inviteAgent('chat-1', 'client-1');
Http::assertSent(fn ($r) => $r['event'] === 'INVITE_AGENT' && $r['chat_id'] === 'chat-1');
});
it('пустой outbound_url (dev/CI) → ничего не шлёт и не падает', function () {
config()->set('services.jivo_bot.outbound_url', '');
Http::fake();
app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'x');
Http::assertNothingSent();
});
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Services\Bot\YandexGptClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
config()->set('services.yandexgpt', [
'api_key' => 'test-key', 'folder_id' => 'b1gtest', 'model' => 'yandexgpt-lite/latest',
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
'timeout_seconds' => 8,
]);
});
it('шлёт правильный запрос и возвращает текст ответа', function () {
Http::fake([
'llm.api.cloud.yandex.net/*' => Http::response([
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
]),
]);
$text = app(YandexGptClient::class)->complete('системный наказ', 'что такое проект?');
expect($text)->toBe('Проект — это…');
Http::assertSent(function ($request) {
return $request->hasHeader('Authorization', 'Api-Key test-key')
&& $request['modelUri'] === 'gpt://b1gtest/yandexgpt-lite/latest'
&& $request['messages'][0]['role'] === 'system'
&& $request['messages'][1]['role'] === 'user'
&& $request['completionOptions']['temperature'] === 0.2;
});
});
it('пустой api_key → null (бот не настроен, не исключение)', function () {
config()->set('services.yandexgpt.api_key', '');
expect(app(YandexGptClient::class)->complete('s', 'u'))->toBeNull();
});
it('ошибка API → null (эскалация решается выше)', function () {
Http::fake(['llm.api.cloud.yandex.net/*' => Http::response('err', 500)]);
expect(app(YandexGptClient::class)->complete('s', 'u'))->toBeNull();
});
+19
View File
@@ -2273,3 +2273,22 @@ srv
поставщиковых
пулер
пуло
gzk
noeviction
автобэкапы
ВТБ
генерится
деплоить
затыков
локал
локалку
ОКПО
онли
преflight
Сбере
синхрона
golive
jivo
дживо
gigachat
+11 -1
View File
@@ -2,7 +2,17 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.57, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.58, консолидированная — разворачивает БД с нуля).
## v8.58 (2026-07-02) — ИИ-бот техподдержки Jivo: knowledge_chunks + bot_dialogs
+`knowledge_chunks` (база знаний ИИ-бота, глобальная, без RLS — публичные статьи инструкции resources/help/*.md;
generated column `search_tsv` russian + GIN-индекс `idx_knowledge_chunks_search`; заполняется командой
`help:rebuild-knowledge`; спека `docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md §3`).
+`bot_dialogs` (журнал диалогов бота, глобальная, без RLS — ПДн клиентов не пишем; `direction in/out`,
`matched_chunks JSONB`, `latency_ms`, `escalated`; индекс `idx_bot_dialogs_chat (jivo_chat_id, created_at)`;
спека §5). Счётчики: таблиц 79→81 (regular 69→71) / индексов 124→126. Миграции 2026_07_02_100001/100002.
## v8.57 (2026-06-26) — RLS GUC hardening: NULLIF во всех политиках tenant_isolation (инцидент входа)
+82 -2
View File
@@ -1,6 +1,7 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.57 (26.06.2026 — RLS GUC hardening: ВСЕ 44 политики tenant_isolation приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint — устранён класс отказов на Managed PG/PgBouncer, когда GUC app.current_tenant_id пуст ('' → 22P02) либо не задан (→ 42704). Инцидент 26.06: вход в портал падал на резолве users (60 ошибок, все на users). 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно получили разрешающую ветку «NULLIF(...) IS NULL OR ...» — читаются/пишутся ДО tenant-контекста на auth-роутах без 'tenant' middleware; при ЗАДАННОМ tenant изоляция НЕ меняется (rls-reviewer APPROVE). Структурно БД НЕ меняется — переписаны только USING/WITH CHECK (счётчики таблиц/индексов/RLS=44/функций/триггеров без изменений). Миграция 2026_06_26_153000_rls_nullif_guc_hardening (идемпотентна). Применена на боевой кластер; lead_charges FORCE RLS сохранён.)
-- Версия: v8.58 (02.07.2026 — ИИ-бот техподдержки Jivo: +2 таблицы (knowledge_chunks — база знаний FTS russian GIN, глобальная без RLS; bot_dialogs — журнал диалогов, глобальная без RLS) + 2 индекса (idx_knowledge_chunks_search GIN, idx_bot_dialogs_chat). Спека docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md. Миграции 2026_07_02_100001/100002. Счётчики: таблиц 79→81 (regular 69→71) / индексов 124→126. RLS/функций/триггеров без изменений.)
-- Базовая версия: v8.57 (26.06.2026 — RLS GUC hardening: ВСЕ 44 политики tenant_isolation приведены к NULLIF(current_setting('app.current_tenant_id', true), '')::bigint — устранён класс отказов на Managed PG/PgBouncer, когда GUC app.current_tenant_id пуст ('' → 22P02) либо не задан (→ 42704). Инцидент 26.06: вход в портал падал на резолве users (60 ошибок, все на users). 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes, user_sessions) дополнительно получили разрешающую ветку «NULLIF(...) IS NULL OR ...» — читаются/пишутся ДО tenant-контекста на auth-роутах без 'tenant' middleware; при ЗАДАННОМ tenant изоляция НЕ меняется (rls-reviewer APPROVE). Структурно БД НЕ меняется — переписаны только USING/WITH CHECK (счётчики таблиц/индексов/RLS=44/функций/триггеров без изменений). Миграция 2026_06_26_153000_rls_nullif_guc_hardening (идемпотентна). Применена на боевой кластер; lead_charges FORCE RLS сохранён.)
-- Базовая версия: v8.56 (26.06.2026 — Путь А, шов C: тело функции audit_block_mutation() пропускает пересчёт hash-цепочки по метке GUC app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО session_replication_role (superuser-only, недоступен в Yandex Managed PG). Append-only сохранён: без метки UPDATE/DELETE аудита запрещён. Структурно БД НЕ меняется — только тело функции (счётчики таблиц/индексов/RLS/функций/триггеров без изменений, функций по-прежнему 5). Миграция 2026_06_26_140000_audit_block_mutation_guc_rebuild_flag. AuditRebuildChain команда переведена на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe).)
-- Базовая версия: v8.55 (25.06.2026 — Эпик 5 отчёт заливки: +1 таблица supplier_sync_runs (сводка по вечерней заливке проектов поставщику — групп/синк/ручная/отложено/упало + status; SaaS-level без RLS/tenant_id как supplier_csv_reconcile_log, пишет crm_supplier_worker BYPASSRLS, читает SaaS-admin) + 1 явный индекс idx_supplier_sync_runs_created. Миграция 2026_06_25_130000. Структурно +1 regular-таблица + 1 индекс. NB: сводные счётчики несут известный дрейф рантайм-счётчика (ср. сверка 23.06) — точная пересверка отдельным canon-sync. RLS/функций/триггеров без изменений.)
-- Базовая версия: v8.54 (25.06.2026 — Эпик 4 online-defer: +1 таблица supplier_deferred_sync (системная очередь отложенных онлайн-правок в окне 18:00→00:00 МСК, project_id PK → projects ON DELETE CASCADE, без RLS/tenant_id — доступ только crm_supplier_worker BYPASSRLS, покрыт blanket-грантом ON ALL TABLES в db/02_grants.sql как supplier_manual_sync_queue). Миграция 2026_06_25_120000. Структурно +1 regular-таблица; явных CREATE INDEX +0 (PK неявный). NB: сводные счётчики таблиц/индексов несут известный дрейф рантайм-счётчика (ср. сверка 23.06 RLS 42→44) — точная пересверка отдельным canon-sync. RLS/функций/триггеров без изменений.)
@@ -29,7 +30,7 @@
-- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns)
-- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
-- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
-- Метрики: 79 базовых таблиц (69 regular + 10 partitioned parents: deals + supplier_lead_costs + 6 audit + lead_region_resolution_log + project_routing_snapshots) + 16 партиций / 124 индекса (+1 явный: idx_supplier_sync_runs_created) / 44 RLS-политик / 5 функций / 15 триггеров
-- Метрики: 81 базовая таблица (71 regular + 10 partitioned parents: deals + supplier_lead_costs + 6 audit + lead_region_resolution_log + project_routing_snapshots) + 16 партиций / 126 индексов (+1 явный: idx_supplier_sync_runs_created; +idx_knowledge_chunks_search GIN; +idx_bot_dialogs_chat) / 44 RLS-политик / 5 функций / 15 триггеров
-- NB (23.06.2026 сверка-чистка перед запуском): RLS-политик исправлено 42→44 — реальное число активных CREATE POLICY в теле == 44 == на боевом liderra.ru (pg_policies). Расхождение было исторический недоучёт в бегущем счётчике шапки (tenant_requisites v8.43 и др. добавлены в тело без правки сводной метрики). Структурно БД не менялась — только корректность числа.
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
@@ -3639,6 +3640,85 @@ BEGIN
END IF;
END $$;
-- =============================================================================
-- knowledge_chunks — база знаний ИИ-бота (v8.58, спека 2026-07-02-jivo-ai-support-bot-design §3)
-- =============================================================================
-- Глобальная таблица (НЕ tenant-scoped): только публичные статьи клиентской инструкции,
-- данных клиентов здесь нет по определению — RLS не требуется.
-- search_tsv — generated column (russian): title+topics+content → tsvector + GIN-индекс.
-- Заполняется командой help:rebuild-knowledge (ночной schedule 04:30).
-- Миграция 2026_07_02_100001_create_knowledge_chunks_table.
-- =============================================================================
CREATE TABLE knowledge_chunks (
id BIGSERIAL PRIMARY KEY,
source_path VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
tour VARCHAR(100),
topics TEXT NOT NULL DEFAULT '',
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
search_tsv tsvector GENERATED ALWAYS AS (
to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(topics, '') || ' ' || coalesce(content, ''))
) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_knowledge_chunks_source_chunk UNIQUE (source_path, chunk_index)
);
CREATE INDEX idx_knowledge_chunks_search ON knowledge_chunks USING GIN (search_tsv);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_chunks TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE knowledge_chunks_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_chunks TO crm_app_admin;
GRANT USAGE, SELECT ON SEQUENCE knowledge_chunks_id_seq TO crm_app_admin;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_chunks TO crm_migrator;
GRANT USAGE, SELECT ON SEQUENCE knowledge_chunks_id_seq TO crm_migrator;
END IF;
END $$;
-- =============================================================================
-- bot_dialogs — журнал диалогов ИИ-бота (v8.58, спека §5)
-- =============================================================================
-- Глобальная (НЕ tenant-scoped) в v1: диалоги Jivo анонимны до этапа личных ответов;
-- ПДн клиентов не пишем. direction: in = сообщение клиента, out = ответ бота.
-- Миграция 2026_07_02_100002_create_bot_dialogs_table.
-- =============================================================================
CREATE TABLE bot_dialogs (
id BIGSERIAL PRIMARY KEY,
jivo_chat_id VARCHAR(64) NOT NULL,
direction VARCHAR(3) NOT NULL CHECK (direction IN ('in', 'out')),
message TEXT NOT NULL,
matched_chunks JSONB,
latency_ms INTEGER,
escalated BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_bot_dialogs_chat ON bot_dialogs (jivo_chat_id, created_at);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT, INSERT ON bot_dialogs TO crm_app_user;
GRANT USAGE, SELECT ON SEQUENCE bot_dialogs_id_seq TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN
GRANT SELECT, INSERT ON bot_dialogs TO crm_app_admin;
GRANT USAGE, SELECT ON SEQUENCE bot_dialogs_id_seq TO crm_app_admin;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
GRANT SELECT, INSERT ON bot_dialogs TO crm_migrator;
GRANT USAGE, SELECT ON SEQUENCE bot_dialogs_id_seq TO crm_migrator;
END IF;
END $$;
-- =============================================================================
-- КОНЕЦ schema.sql
-- =============================================================================
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,702 @@
# Jivo Bot Tours (этап 3 спеки ИИ-бота) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Кнопка «Показать на портале» из ответа бота реально работает: ссылка `/?tour=<имя>` открывает нужный экран и ведёт клиента экскурсией с подсветкой полей (по образцу WelcomeTour).
**Architecture:** `tours/catalog.ts` (реестр сценариев: имя → шаги {route, target, title, text}) → `GuidedTour.vue` (обобщённый раннер: подсветка цели, «ждущие» шаги — цель может появиться после действия клиента, напр. открытия диалога) → запуск из `AppLayout` по `route.query.tour` (после логина query переживает redirect — роутер уже сохраняет `to.fullPath`). Якоря шагов — существующие `[data-tour]`/`[data-testid]` + 2 новых `data-tour`. Бот уже умеет прикладывать ссылку (флаг `JIVO_BOT_TOURS_ENABLED`, BotAnswerService — этап 2).
**Tech Stack:** Vue 3 + Vuetify 3, vue-router, Vitest (tests/Frontend), Pest (кросс-проверка frontmatter↔каталог).
**Спека:** `docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md` §4 · **Ядро (этап 2):** план `2026-07-02-jivo-bot-core.md`, выполнен 02.07.2026.
**Правила окружения (те же, что в плане ядра):** worktree `jivo-bot-core`, ветка `worktree-jivo-bot-core`; Vitest: `npm run test:vue -- --run <файл>`; Pest: `DB_DATABASE=liderra_testing_jivo php -d memory_limit=2G artisan test --filter=<Имя>`, НИКОГДА полный набор/--parallel в задачах; TDD строго; коммиты явными путями.
**Файловая карта:**
| Файл | Что | Ответственность |
|---|---|---|
| `app/resources/js/tours/catalog.ts` | Создать | реестр экскурсий (типы + 5 сценариев) |
| `app/resources/js/components/layout/GuidedTour.vue` | Создать | обобщённый раннер (подсветка/шаги/ожидание цели) |
| `app/resources/js/composables/useTourLauncher.ts` | Создать | чтение `?tour=`, запуск, очистка query |
| `app/resources/js/layouts/AppLayout.vue` | Изменить | подключить GuidedTour + launcher рядом с WelcomeTour |
| `app/resources/js/views/ProjectsView.vue` | Изменить | `data-tour="projects-create"` на кнопку «Создать проект» (~строка 5) |
| `app/resources/js/views/BillingView.vue` | Изменить | `data-tour="billing-topup"` на кнопку «Пополнить баланс» (~строка 89) |
| `app/tests/Frontend/TourCatalog.spec.ts`, `GuidedTour.spec.ts`, `TourLauncher.spec.ts` | Создать | Vitest |
| `app/tests/Feature/Bot/HelpTourNamesTest.php` | Создать | каждый `tour:` из resources/help существует в каталоге |
| `ПРОТОКОЛ-ии-дживосайт.md`, спека §4 | Изменить | отметка «этап 3 построен» |
---
### Task 1: Каталог экскурсий `tours/catalog.ts`
**Files:**
- Create: `app/resources/js/tours/catalog.ts`
- Test: `app/tests/Frontend/TourCatalog.spec.ts`
- [ ] **Step 1: Падающий тест** `app/tests/Frontend/TourCatalog.spec.ts`:
```ts
import { describe, expect, it } from 'vitest';
import { TOURS, findTour, type TourScenario } from '../../resources/js/tours/catalog';
describe('каталог экскурсий', () => {
it('содержит 5 стартовых сценариев с уникальными именами', () => {
const names = TOURS.map((t: TourScenario) => t.name);
expect(names).toEqual([...new Set(names)]);
for (const required of ['create-project', 'top-up-balance', 'tariffs', 'change-source', 'notifications']) {
expect(names).toContain(required);
}
});
it('каждый шаг имеет route, target, title и text', () => {
for (const tour of TOURS) {
expect(tour.steps.length).toBeGreaterThan(0);
for (const s of tour.steps) {
expect(s.route.startsWith('/')).toBe(true);
expect(s.target).toBeTruthy();
expect(s.title).toBeTruthy();
expect(s.text).toBeTruthy();
}
}
});
it('findTour находит по имени и отдаёт null на мусор', () => {
expect(findTour('create-project')?.name).toBe('create-project');
expect(findTour('no-such-tour')).toBeNull();
expect(findTour('')).toBeNull();
});
});
```
- [ ] **Step 2: Убедиться, что падает:** `npm run test:vue -- --run tests/Frontend/TourCatalog.spec.ts` → FAIL (module not found)
- [ ] **Step 3: Реализация** `app/resources/js/tours/catalog.ts`:
```ts
/**
* Каталог экскурсий «Показать на портале» (спека ИИ-бота §4).
* ИИ шаги НЕ сочиняет — только выбирает готовый сценарий по имени
* (frontmatter `tour:` статьи resources/help). Селекторы целей — существующие
* data-tour (sidebar: nav-*) и data-testid; target может появиться ПОСЛЕ
* действия клиента (открыл диалог) — раннер умеет ждать (см. GuidedTour).
*/
export interface TourStep {
/** Роут, на котором живёт цель шага; раннер переходит туда сам. */
route: string;
/** CSS-селектор цели подсветки. */
target: string;
title: string;
text: string;
}
export interface TourScenario {
name: string;
steps: TourStep[];
}
export const TOURS: TourScenario[] = [
{
name: 'create-project',
steps: [
{
route: '/projects',
target: '[data-tour="nav-projects"]',
title: 'Раздел «Проекты»',
text: 'Здесь живут все ваши проекты — заявки на поток клиентов.',
},
{
route: '/projects',
target: '[data-tour="projects-create"]',
title: 'Создать проект',
text: 'Нажмите эту кнопку — откроется форма нового проекта. Понадобятся название, источник и дневной лимит заявок.',
},
],
},
{
name: 'top-up-balance',
steps: [
{
route: '/billing',
target: '[data-tour="nav-billing"]',
title: 'Раздел «Биллинг»',
text: 'Баланс, история операций и пополнение — всё здесь.',
},
{
route: '/billing',
target: '[data-tour="billing-topup"]',
title: 'Пополнить баланс',
text: 'Нажмите, чтобы выставить счёт на пополнение. После оплаты деньги зачислятся автоматически.',
},
],
},
{
name: 'tariffs',
steps: [
{
route: '/billing',
target: '[data-tour="nav-billing"]',
title: 'Тарифы — в «Биллинге»',
text: 'Вы платите только за полученные заявки. Актуальные цены и ваша тарифная ступень — в этом разделе.',
},
],
},
{
name: 'change-source',
steps: [
{
route: '/projects',
target: '[data-tour="nav-projects"]',
title: 'Смена источника — в «Проектах»',
text: 'Откройте нужный проект — в его настройках можно сменить источник без потери заявок.',
},
],
},
{
name: 'notifications',
steps: [
{
route: '/settings',
target: '[data-tour="nav-settings"]',
title: 'Уведомления — в «Настройках»',
text: 'Здесь включаются письма о новых заявках и другие уведомления.',
},
],
},
];
export function findTour(name: string): TourScenario | null {
if (name === '') return null;
return TOURS.find((t) => t.name === name) ?? null;
}
```
- [ ] **Step 4: Зелёный:** `npm run test:vue -- --run tests/Frontend/TourCatalog.spec.ts` → PASS (3)
- [ ] **Step 5: Проверить якорь nav-settings**`grep -n "nav-settings\|/settings" app/resources/js/components/layout/AppSidebar.vue`: data-tour генерится как `nav-${item.to.replace('/', '')}` (AppSidebar.vue:106) → у пункта `/settings` якорь есть автоматически. Если пункта настроек в сайдбаре нет — заменить шаг notifications на target `[data-tour="nav-help"]` с текстом про «Помощь» и зафиксировать в каталоге комментарием.
- [ ] **Step 6: Commit**
```bash
git add app/resources/js/tours/catalog.ts app/tests/Frontend/TourCatalog.spec.ts
git commit -m "feat(tours): каталог экскурсий — 5 стартовых сценариев"
```
---
### Task 2: Раннер `GuidedTour.vue`
**Files:**
- Create: `app/resources/js/components/layout/GuidedTour.vue`
- Test: `app/tests/Frontend/GuidedTour.spec.ts`
Обобщение WelcomeTour (`components/layout/WelcomeTour.vue` — образец разметки/стилей): шаги приходят пропсом, тур активируется методом, цель шага может появиться позже (диалог) — меряем с ретраем.
- [ ] **Step 1: Падающий тест** `app/tests/Frontend/GuidedTour.spec.ts`:
```ts
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue';
import type { TourStep } from '../../resources/js/tours/catalog';
const steps: TourStep[] = [
{ route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
];
function mountTour() {
return mount(GuidedTour, {
props: { steps, active: true },
global: { stubs: { 'v-btn': { template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
});
}
describe('GuidedTour', () => {
it('показывает первый шаг и счётчик', () => {
const w = mountTour();
expect(w.text()).toContain('Шаг 1');
expect(w.text()).toContain('1 из 2');
});
it('Далее ведёт по шагам, на последнем — Готово и finish', async () => {
const w = mountTour();
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.text()).toContain('Шаг 2');
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('Пропустить завершает тур сразу', async () => {
const w = mountTour();
await w.find('[data-testid="tour-skip"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('цель не найдена → карточка по центру (targetRect null), без падения', () => {
const w = mountTour();
expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true);
});
it('ретрай измерения: цель появляется позже — подсветка находит её', async () => {
vi.useFakeTimers();
const w = mountTour();
const el = document.createElement('div');
el.setAttribute('data-tour', 'a');
document.body.appendChild(el);
await vi.advanceTimersByTimeAsync(1000);
expect((w.vm as any).targetRect).not.toBeNull();
el.remove();
vi.useRealTimers();
});
});
```
- [ ] **Step 2: Падает:** `npm run test:vue -- --run tests/Frontend/GuidedTour.spec.ts` → FAIL
- [ ] **Step 3: Реализация** `app/resources/js/components/layout/GuidedTour.vue`:
```vue
<script setup lang="ts">
/**
* GuidedTour — обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
* действия клиента (открыл диалог) — меряем с ретраем каждые 300мс до 15с.
* Разметка/стили — по образцу WelcomeTour (единый вид подсказок).
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { TourStep } from '../../tours/catalog';
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
const emit = defineEmits<{ finish: [] }>();
const RETRY_MS = 300;
const RETRY_MAX = 50; // 15 сек
const stepIndex = ref(0);
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
let retryTimer: ReturnType<typeof setInterval> | null = null;
const currentStep = computed(() => props.steps[stepIndex.value]);
const isLast = computed(() => stepIndex.value === props.steps.length - 1);
function stopRetry(): void {
if (retryTimer !== null) {
clearInterval(retryTimer);
retryTimer = null;
}
}
function measure(): void {
stopRetry();
targetRect.value = null;
const sel = currentStep.value?.target;
if (!sel) return;
let attempts = 0;
const tryMeasure = (): void => {
const el = document.querySelector(sel);
if (el) {
const r = el.getBoundingClientRect();
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
stopRetry();
return;
}
attempts += 1;
if (attempts >= RETRY_MAX) stopRetry();
};
tryMeasure();
if (targetRect.value === null) {
retryTimer = setInterval(tryMeasure, RETRY_MS);
}
}
const highlightStyle = computed(() => {
const r = targetRect.value;
if (!r) return { display: 'none' };
const pad = 6;
return {
top: `${r.top - pad}px`,
left: `${r.left - pad}px`,
width: `${r.width + pad * 2}px`,
height: `${r.height + pad * 2}px`,
};
});
const tooltipStyle = computed(() => {
const r = targetRect.value;
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
return { top: `${Math.max(12, r.top)}px`, left: `${r.left + r.width + 16}px` };
});
function next(): void {
if (isLast.value) {
finish();
return;
}
stepIndex.value += 1;
measure();
}
function finish(): void {
stopRetry();
emit('finish');
}
function onResize(): void {
if (props.active) measure();
}
watch(
() => props.active,
(on) => {
if (on) {
stepIndex.value = 0;
requestAnimationFrame(() => measure());
window.addEventListener('resize', onResize);
} else {
stopRetry();
window.removeEventListener('resize', onResize);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopRetry();
window.removeEventListener('resize', onResize);
});
defineExpose({ stepIndex, targetRect, next, finish });
</script>
<template>
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour">
<div class="guided-tour__backdrop" />
<div v-if="targetRect" class="guided-tour__highlight" :style="highlightStyle" />
<div class="guided-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
<div class="guided-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
<h3 class="guided-tour__title">{{ currentStep.title }}</h3>
<p class="guided-tour__text">{{ currentStep.text }}</p>
<div class="guided-tour__actions">
<v-btn variant="text" size="small" data-testid="tour-skip" @click="finish">Закрыть</v-btn>
<v-btn color="primary" variant="flat" size="small" data-testid="tour-next" @click="next">
{{ isLast ? 'Готово' : 'Далее' }}
</v-btn>
</div>
</div>
</div>
</template>
<style scoped>
.guided-tour {
position: fixed;
inset: 0;
z-index: 3000;
pointer-events: none;
}
.guided-tour__backdrop {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.55);
pointer-events: auto;
}
.guided-tour__highlight {
position: absolute;
border: 2px solid var(--liderra-teal, #0f6e56);
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(1, 32, 25, 0.55);
transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.guided-tour__card {
position: absolute;
width: 300px;
max-width: calc(100vw - 24px);
background: #fff;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
pointer-events: auto;
}
.guided-tour__step {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #6b7470;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.guided-tour__title {
font-size: 16px;
font-weight: 600;
margin: 4px 0 6px;
color: #081319;
}
.guided-tour__text {
font-size: 13px;
line-height: 1.45;
color: #3a423f;
margin: 0 0 14px;
}
.guided-tour__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
- [ ] **Step 4: Зелёный:** `npm run test:vue -- --run tests/Frontend/GuidedTour.spec.ts` → PASS (5)
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/components/layout/GuidedTour.vue app/tests/Frontend/GuidedTour.spec.ts
git commit -m "feat(tours): GuidedTour — обобщённый раннер с ожиданием цели"
```
---
### Task 3: Запуск по `?tour=``useTourLauncher` + AppLayout
**Files:**
- Create: `app/resources/js/composables/useTourLauncher.ts`
- Modify: `app/resources/js/layouts/AppLayout.vue` (импорты ~строка 23, template ~строка 94 рядом с WelcomeTour)
- Test: `app/tests/Frontend/TourLauncher.spec.ts`
- [ ] **Step 1: Падающий тест** `app/tests/Frontend/TourLauncher.spec.ts`:
```ts
import { describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useTourLauncher } from '../../resources/js/composables/useTourLauncher';
function makeRouterMocks(query: Record<string, string>) {
const route = ref({ query, fullPath: '/x' });
const router = { push: vi.fn().mockResolvedValue(undefined), replace: vi.fn().mockResolvedValue(undefined) };
return { route, router };
}
describe('useTourLauncher', () => {
it('валидный ?tour= → активирует сценарий и ведёт на роут первого шага', async () => {
const { route, router } = makeRouterMocks({ tour: 'create-project' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value?.name).toBe('create-project');
expect(router.push).toHaveBeenCalledWith({ path: '/projects', query: {} });
});
it('мусорный ?tour= → игнор без падения, query чистится', async () => {
const { route, router } = makeRouterMocks({ tour: 'no-such' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value).toBeNull();
expect(router.replace).toHaveBeenCalled();
});
it('без ?tour= — ничего не делает', async () => {
const { route, router } = makeRouterMocks({});
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
expect(l.activeTour.value).toBeNull();
expect(router.push).not.toHaveBeenCalled();
});
it('finishTour гасит активный тур', async () => {
const { route, router } = makeRouterMocks({ tour: 'tariffs' });
const l = useTourLauncher(route as never, router as never);
await l.checkQuery();
l.finishTour();
expect(l.activeTour.value).toBeNull();
});
});
```
- [ ] **Step 2: Падает:** `npm run test:vue -- --run tests/Frontend/TourLauncher.spec.ts` → FAIL
- [ ] **Step 3: Реализация** `app/resources/js/composables/useTourLauncher.ts`:
```ts
/**
* Запуск экскурсии по ссылке из чата бота: /?tour=<имя> (спека ИИ-бота §4).
* Невошедшего роутер сам отправит на /login с redirect=fullPath — query
* переживает вход (router/index.ts beforeEach), поэтому отдельной логики
* логина здесь нет. Мусорное имя — молча чистим query (не пугаем клиента).
*/
import { ref, type Ref } from 'vue';
import type { Router } from 'vue-router';
import { findTour, type TourScenario } from '../tours/catalog';
interface RouteLike {
query: Record<string, unknown>;
}
export function useTourLauncher(route: Ref<RouteLike>, router: Router) {
const activeTour = ref<TourScenario | null>(null);
async function checkQuery(): Promise<void> {
const name = typeof route.value.query.tour === 'string' ? route.value.query.tour : '';
if (name === '') return;
const tour = findTour(name);
if (tour === null) {
await router.replace({ query: { ...route.value.query, tour: undefined } });
return;
}
activeTour.value = tour;
await router.push({ path: tour.steps[0].route, query: {} });
}
function finishTour(): void {
activeTour.value = null;
}
return { activeTour, checkQuery, finishTour };
}
```
- [ ] **Step 4: Зелёный:** `npm run test:vue -- --run tests/Frontend/TourLauncher.spec.ts` → PASS (4)
- [ ] **Step 5: Подключить в AppLayout** — в `app/resources/js/layouts/AppLayout.vue`:
в `<script setup>` (рядом с импортом WelcomeTour, ~строка 23):
```ts
import GuidedTour from '../components/layout/GuidedTour.vue';
import { useTourLauncher } from '../composables/useTourLauncher';
```
после объявления `route`/`router` (найти существующие `useRoute()/useRouter()`; если роутер не заведён — добавить):
```ts
const tourLauncher = useTourLauncher(computed(() => route) as never, router);
onMounted(() => {
void tourLauncher.checkQuery();
});
watch(
() => route.query.tour,
() => {
void tourLauncher.checkQuery();
},
);
```
в `<template>` рядом с `<WelcomeTour />` (~строка 94):
```html
<GuidedTour
v-if="tourLauncher.activeTour.value"
:steps="tourLauncher.activeTour.value.steps"
:active="true"
@finish="tourLauncher.finishTour()"
/>
```
NB: точную форму привязки (`computed(() => route)` vs `toRef`) подогнать под фактический код AppLayout — там уже есть `route` (см. строку 91 `route.meta.devIndex`). Прогнать существующий `tests/Frontend/AppLayout.spec.ts`, если есть — не сломать.
- [ ] **Step 6: Прогнать смежные Vitest:** `npm run test:vue -- --run tests/Frontend/TourLauncher.spec.ts tests/Frontend/GuidedTour.spec.ts` → PASS; плюс `npm run type-check` по проекту → 0 новых ошибок.
- [ ] **Step 7: Commit**
```bash
git add app/resources/js/composables/useTourLauncher.ts app/resources/js/layouts/AppLayout.vue app/tests/Frontend/TourLauncher.spec.ts
git commit -m "feat(tours): запуск экскурсии по ?tour= из ссылки бота"
```
---
### Task 4: Якоря `data-tour` на целях
**Files:**
- Modify: `app/resources/js/views/ProjectsView.vue` (~строка 5 — кнопка «Создать проект»)
- Modify: `app/resources/js/views/BillingView.vue` (~строка 89 — кнопка «Пополнить баланс»)
- [ ] **Step 1: ProjectsView** — добавить атрибут кнопке:
```html
<v-btn color="primary" prepend-icon="mdi-plus" data-tour="projects-create" @click="openCreate">Создать проект</v-btn>
```
- [ ] **Step 2: BillingView** — добавить `data-tour="billing-topup"` кнопке «Пополнить баланс» (найти `>Пополнить баланс</v-btn` ~строка 89, атрибут в открывающий тег).
- [ ] **Step 3: Прогнать существующие спеки этих экранов:** `npm run test:vue -- --run tests/Frontend/ProjectsView.spec.ts tests/Frontend/BillingView.spec.ts` (если файлы существуют; иначе пропустить с пометкой) → PASS.
- [ ] **Step 4: Commit**
```bash
git add app/resources/js/views/ProjectsView.vue app/resources/js/views/BillingView.vue
git commit -m "feat(tours): data-tour якоря — кнопки создания проекта и пополнения"
```
---
### Task 5: Кросс-проверка frontmatter ↔ каталог (Pest)
**Files:**
- Test: `app/tests/Feature/Bot/HelpTourNamesTest.php`
- Modify (если надо): `app/resources/help/*.md`
- [ ] **Step 1: Написать тест** `app/tests/Feature/Bot/HelpTourNamesTest.php`:
```php
<?php
declare(strict_types=1);
use App\Support\Help\HelpArticleParser;
it('каждый tour из статей resources/help существует в каталоге экскурсий', function () {
$catalog = (string) file_get_contents(resource_path('js/tours/catalog.ts'));
preg_match_all("/name: '([a-z0-9\\-]+)'/", $catalog, $m);
$known = $m[1];
expect($known)->not->toBeEmpty();
$parser = new HelpArticleParser();
foreach (glob(resource_path('help').'/*.md') ?: [] as $file) {
$article = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
if ($article->tour !== null) {
expect($known)->toContain($article->tour);
}
}
});
```
- [ ] **Step 2: Прогнать:** `DB_DATABASE=liderra_testing_jivo php -d memory_limit=2G artisan test --filter=HelpTourNamesTest` — ожидается PASS сразу (статьи ссылаются на create-project/tariffs/top-up-balance — все в каталоге). Если RED — исправить рассинхрон имени в статье или каталоге, НЕ ослаблять тест.
- [ ] **Step 3: Commit**
```bash
git add app/tests/Feature/Bot/HelpTourNamesTest.php
git commit -m "test(tours): frontmatter tour статей сверяется с каталогом экскурсий"
```
---
### Task 6: Финал — прогоны и синхрон документов
**Files:**
- Modify: `ПРОТОКОЛ-ии-дживосайт.md` (шаг «Этап 3 — экскурсии» → выполнен)
- Modify: `docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md` §4 (пометка «построено 03.07.2026»)
- [ ] **Step 1: Полный Vitest:** `npm run test:vue -- --run` → 0 новых красных против базы (база: 10 пред-сущ. красных фронт-файлов упоминались в памяти launch-gate — сверить фактически, наши файлы зелёные).
- [ ] **Step 2: Bot-часть Pest:** `DB_DATABASE=liderra_testing_jivo php -d memory_limit=2G artisan test --filter="Bot|Tour|Help"` → все зелёные.
- [ ] **Step 3: type-check + pint:** `npm run type-check` (0 новых) + `composer pint` (только наши файлы).
- [ ] **Step 4: Протокол** — отметить «[x] Этап 3 — экскурсии построены (03.07.2026, worktree)»; спека §4 — приписка «Построено 03.07.2026; включение ссылок в ответы бота — флаг JIVO_BOT_TOURS_ENABLED при деплое».
- [ ] **Step 5: Commit**
```bash
git add ПРОТОКОЛ-ии-дживосайт.md docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md
git commit -m "docs(tours): этап 3 построен — протокол и спека синхронизированы"
```
---
## Вне рамок (после этого плана)
1. Полный последовательный Pest-прогон — ДО 21:00 МСК (грабля времени суток — память `feedback-worktree-test-bootstrap-recipe`).
2. Живая проверка глазами (`/run` или вручную): открыть `/?tour=create-project` на локальном стенде.
3. Деплой + включение флага + подключение Jivo Bot API — только с разрешения владельца (О-2/О-3).
## Self-review (выполнен при написании)
- Спека §4 покрыта: каталог (Task 1), раннер (Task 2), `?tour=` + логин-редирект (Task 3), якоря (Task 4), «ИИ не сочиняет шаги» (Task 5 — сверка имён). Кнопка в ответе бота — уже в ядре (BotAnswerService, флаг).
- Плейсхолдеров нет; код полный в каждом шаге; известные неточности среды (наличие AppLayout.spec.ts, ProjectsView.spec.ts, форма привязки route) помечены явно с fallback-инструкцией.
- Типы сквозные: `TourStep{route,target,title,text}`, `TourScenario{name,steps}`, `findTour(): TourScenario|null`, `useTourLauncher(){activeTour,checkQuery,finishTour}` — согласованы между задачами 1–3.
@@ -0,0 +1,146 @@
# Спека: свой ИИ-бот техподдержки в чате JivoSite («Консультант»)
**Дата:** 02.07.2026
**Статус:** дизайн согласован владельцем (куски 1–4 приняты в диалоге 02.07.2026)
**Протокол обсуждения:** `ПРОТОКОЛ-ии-дживосайт.md` (корень репо) — решения 1–8, открытые вопросы О-2/О-3
**Связанные материалы:** `вебмастер-исходники-perplexity/Б3-ии-техподдержка.md` (архитектурное руководство RAG-поддержки), спека G7-A (`2026-06-19-g7a-client-support-design.md`), спека G7-B (`2026-06-19-g7b-impersonation-door-design.md`), мониторинг внешних сервисов (`2026-07-02-external-services-monitoring-design.md`)
---
## 1. Цель
Клиент портала пишет в чат (виджет JivoSite в личном кабинете) — наш собственный ИИ-бот
консультирует его по работе портала за 2–5 секунд и, где уместно, прикладывает кнопку
«Показать на портале», запускающую экскурсию с подсветкой полей. Не готовый «ИИ-оператор»
Jivo (12 990 ₽/мес, не видит наших данных и не умеет экскурсий), а свой бот через **Jivo Bot API**:
окошко чата — Jivo, мозги — наш сервер.
**Ключевые решения владельца** (протокол, решения 1–8):
свой бот · внутри чата Jivo (Bot API) · v1 отвечает только на общие вопросы · сканирование
сайта Jivo не используем · ответ + кнопка «Показать» (экскурсии) · база знаний = клиентская
инструкция в репо, обновляется с каждой фичей · скорость 2–5 сек — жёсткое требование ·
мозг — YandexGPT Lite.
## 2. Архитектура (v1)
```
Клиент → виджет Jivo (уже встроен, JivoWidget.vue)
→ серверы Jivo → POST https://liderra.ru/api/webhook/jivo/<секрет> [CLIENT_MESSAGE]
→ JivoBotController (ack ≤3 сек) → ProcessJivoMessageJob (очередь, приоритетная)
→ KnowledgeSearch (PostgreSQL full-text search russian + синонимы topics,
top 3–5 фрагментов resources/help/; отступление от pgvector согласовано 02.07.2026 —
расширения нет, корпус мал, скорость важнее; интерфейс допускает замену)
→ YandexGptClient (Lite, строгий системный промпт)
→ BOT_MESSAGE обратно в Jivo (текст + при наличии — ссылка-экскурсия)
→ журнал bot_dialogs
Fallback: нет ответа за ~10 сек / стоп-тема / просьба «человека» → INVITE_AGENT (живой оператор).
Jivo сам зовёт оператора, если BOT_MESSAGE не пришёл за 15 сек — двойная страховка.
```
Компоненты:
| Компонент | Назначение |
|---|---|
| `JivoBotController` | Приём webhook от Jivo: секрет в URL + валидация payload; мгновенный ack; постановка job |
| `ProcessJivoMessageJob` | Оркестратор ответа: поиск → LLM → отправка → журнал. Таймаут-бюджет ~10 сек |
| `KnowledgeSearch` | Полнотекстовый поиск по `knowledge_chunks` (tsvector russian + GIN, ts_rank), top-N фрагментов |
| `YandexGptClient` | Вызов YandexGPT Lite (Yandex Cloud Foundation Models API), таймаут 8 сек |
| `help:rebuild-knowledge` | Artisan-команда (schedule 04:30 МСК + heartbeat): перечитать `resources/help/*.md`, порезать на чанки, перезалить `knowledge_chunks` |
| `JivoBotLivenessProbe`-расширение | Метрики бота на плитке «Внешние сервисы»: жив, p95 времени ответа, % эскалаций |
## 3. База знаний
- **Источник — `app/resources/help/*.md`** (внутри приложения — уезжают на прод вместе с кодом): статьи простым языком по темам (что такое проект, тарифы
и списания, пополнение, смена источника, почему проект остановился, уведомления…).
Пишет Claude, вычитывает владелец. Эти же статьи — будущий раздел «Справка» для людей.
- **Frontmatter статьи**: `title`, `tour` (имя экскурсии из каталога, опционально), `topics`.
- **Индексация**: ночная команда `help:rebuild-knowledge` — режет статьи на чанки (~1200 симв.
по абзацам), перезаливает `knowledge_chunks` в транзакции (поисковый вектор считает сама БД,
generated column). Изменилась статья — ночью бот знает новое; срочно — ручной запуск команды.
- **Правило против устаревания** (норматив): **фича не готова, пока не обновлена клиентская
инструкция** — добавить в CLAUDE.md через плагин claude-md-management при реализации.
- **Чего в базе нет никогда**: данных клиентов, внутренней кухни (поставщик, секреты, админ-процессы),
всего, что нельзя показать любому клиенту.
## 4. Ответ и «Показать на портале» (экскурсии)
- Ответ = текст (только по найденным фрагментам) + если у статьи-источника задана `tour`
ссылка-кнопка `https://liderra.ru/?tour=<имя>`.
- **Каталог экскурсий** в портале: реестр сценариев (имя → шаги: экран, селектор элемента,
текст подсказки). Переиспользует механизм WelcomeTour. Первый набор 5–7: `create-project`,
`top-up-balance`, `change-source`, `tariffs`, `notifications`
- Обработчик `?tour=`: клиент вошёл → открыть нужный экран, запустить шаги; не вошёл →
сначала логин, после входа — экскурсия.
- ИИ **не сочиняет шаги** — только выбирает готовую экскурсию из каталога (через frontmatter статьи).
> **Построено 03.07.2026**; включение ссылок в ответах бота — флаг `JIVO_BOT_TOURS_ENABLED` при деплое.
## 5. Безопасность и 152-ФЗ
- Webhook: секрет в URL (по образцу приёма лидов поставщика) + проверка структуры/подписи
payload Jivo; посторонние запросы — 403 без утечки деталей.
- **Изоляция v1**: у бота нет доступа к данным клиентов вообще — job работает через соединение,
видящее только `knowledge_chunks` и `bot_dialogs`. Prompt-injection «покажи чужой баланс»
упирается в отсутствие данных физически.
- Системный промпт: отвечать ТОЛЬКО по контексту; не знаешь — скажи честно и предложи
человека; стоп-темы (личные деньги/данные, обещания скидок, юр-советы) → всегда эскалация.
- Тракт данных целиком в РФ: Jivo (заявляет хранение в РФ) → наш сервер (Yandex Cloud) →
YandexGPT (Yandex Cloud). В политику конфиденциальности добавить упоминание Jivo.
- Журнал `bot_dialogs` — новая таблица (не tenant-scoped в v1: диалоги анонимны до этапа
личных ответов): `id, jivo_chat_id, direction, message, matched_chunks, latency_ms,
escalated, created_at`. Запись в `db/schema.sql` + CHANGELOG по правилам.
## 6. Скорость (жёсткая планка владельца)
- Цель: ответ клиенту 2–5 сек. Бюджет: ack ≤1 c → очередь ≤1 c → поиск ≤0.3 c → LLM ≤3 c →
отправка ≤0.5 c.
- Тест производительности: p95 полного цикла (mock LLM с реальными задержками) ≤5 с;
live-smoke при приёмке.
- Не успели за ~10 с → сами шлём INVITE_AGENT; Jivo добивает страховкой на 15 с.
## 7. Эскалация на человека
- Триггеры: просьба клиента («человека», «оператора»), стоп-тема, низкая уверенность
(пустой/слабый поиск), таймаут.
- INVITE_AGENT → диалог у живого оператора (владелец, приложение Jivo на телефоне).
- Существующий канал G7-A (форма «Помощь» + почта) остаётся без изменений — запасной путь.
## 8. Мониторинг
- Плитка «Внешние сервисы» (выкачена 02.07.2026): статус бота, p95 latency,
доля эскалаций, диалогов/день. Красный при недоступности YandexGPT или росте таймаутов.
- Алерты — по образцу email edge-trigger внешних сервисов.
## 9. Деньги
- Jivo корпоративный (Bot API): ~3 142 ₽/мес за оператора. Подключение бота — письмом
в info@jivosite.com (адрес endpoint + токен) — О-2 протокола.
- YandexGPT Lite + эмбеддинги: при сотнях диалогов/мес — порядка 100–300 ₽/мес.
- Итого ~3.5 тыс. ₽/мес против 12 990 ₽/мес за готовый ИИ-оператор Jivo (который без экскурсий).
## 10. Этапы
1. **Разведка (без кода):** включить бесплатный чат Jivo (JIVO_WIDGET_ID — О-3, разрешение
владельца), владелец отвечает сам; копим реальные вопросы. Параллельно — статьи `resources/help/`.
2. **Бот-консультант (ядро v1):** webhook + job + поиск + YandexGPT + эскалация + журнал +
тесты скорости. TDD, worktree, не прод.
3. **Экскурсии:** каталог + `?tour=` + кнопка в ответах + 5–7 сценариев.
4. **Позже, отдельным решением владельца:** личные ответы через машинный ключ G7-B (`lpimp_`),
отдельная спека (сцепка «кто в чате = какой клиент», tenant-изоляция, RLS).
## 11. Вне рамок v1
- Личные данные в ответах (этап 4, отдельная спека).
- Каналы Telegram/WhatsApp через Jivo (возможны позже — Bot API канал-агностичен).
- Автоматический показ UI без клика (решение владельца: сначала кнопка).
- Собственное чат-окошко вместо Jivo (отклонено владельцем 02.07.2026 в пользу Jivo).
## 12. Критерии приёмки v1 (этапы 2–3)
- Живой вопрос «что такое проект?» в чате портала → осмысленный ответ по инструкции
≤5 сек + рабочая кнопка «Показать» → экскурсия подсвечивает форму создания проекта.
- Вопрос «какой у меня баланс?» → вежливая передача живому оператору (INVITE_AGENT приходит).
- Вопрос не из инструкции («какая погода») → честное «не знаю, позвать человека?».
- Обновление статьи + ночная джоба → бот отвечает по-новому.
- Pest: контроллер (секрет/403), job (бюджет времени, эскалации, журнал), поиск (релевантность
на фикстурах), клиент YandexGPT (таймаут/ретрай); Vitest: обработчик `?tour=`, каталог экскурсий.
@@ -0,0 +1,75 @@
# ПРОТОКОЛ — ИИ-бот техподдержки в чате ДживоСайт
**Заведён:** 02.07.2026 по указанию владельца. Здесь фиксируется всё, что обсуждаем и решаем
по теме «свой ИИ-бот, который консультирует клиентов портала». Обновляется по ходу обсуждений.
---
## 1. Что решено (по состоянию на 02.07.2026)
| № | Решение | Кто/когда |
|---|---|---|
| 1 | Делаем **своего бота**, а не готовый «ИИ-оператор» Jivo (12 990 ₽/мес). Готовый умеет консультировать только по загруженной базе знаний и не видит данных клиента; свой — полностью наш и растёт вместе с порталом. | Владелец, 02.07.2026 |
| 2 | Бот живёт **внутри чата Jivo** (механизм Bot API): окошко чата — Jivo, мозги — наш сервер. Клиент пишет в чат → Jivo пересылает нам → наш бот отвечает. | Владелец, 02.07.2026 |
| 3 | Первая версия отвечает на **общие вопросы** (как работает портал, тарифы, как создать проект…). Личные («какой у меня баланс») — вторым этапом; пока такие вопросы бот передаёт живому человеку. | Владелец, 02.07.2026 |
| 4 | **Сканирование сайта роботом Jivo не используем** — базу знаний собираем только из своих проверенных материалов. | Владелец, 02.07.2026 |
| 5 | Бот не только отвечает словами, но и **показывает на портале**: к ответу прикладывается кнопка «Показать» — клик открывает нужную форму и запускает экскурсию с подсветкой полей (механизм экскурсий в портале уже есть — WelcomeTour). Вариант «бот сам двигает экран без клика» — возможное развитие потом. | Владелец, 02.07.2026 |
| 6 | **Обучение бота**: база знаний = клиентская инструкция, которая живёт в проекте рядом с кодом. Правило: сделали новую функцию — обновили инструкцию (обязанность Claude при каждой фиче). Бот перечитывает инструкцию автоматически (ночная джоба) — не отстаёт от портала. | Согласовано, 02.07.2026 |
| 7 | **Скорость — жёсткое требование владельца**: никаких «думает 30–40 секунд». Целевой ответ — 2–5 секунд. Чат Jivo и сам обрывает бота на 15 секундах (передаёт человеку) — строим быстрого по определению. | Владелец, 02.07.2026 |
| 8 | **Мозг бота — YandexGPT Lite** (Яндекс Облако): ответ ~1–3 сек, данные в РФ, один счёт с нашим облаком, копейки за ответ. | Владелец, 02.07.2026 |
## 2. Открытые вопросы (не закрыты, ждут решения владельца)
| № | Вопрос | Варианты / заметки |
|---|---|---|
| О-1 | ~~Какой ИИ-«мозг»~~ | **ЗАКРЫТ 02.07.2026** → решение 8: YandexGPT Lite. |
| О-2 | Тариф Jivo | Bot API официально доступен на корпоративном тарифе (~3 142 ₽/мес за оператора). Подтвердить готовность платить, когда дойдём до подключения. Подключение своего бота — по письму в поддержку Jivo (info@jivosite.com), автоматической кнопки нет. |
| О-3 | Когда включаем бесплатный чат Jivo (без бота, для разведки вопросов) | Код в портале готов, нужен только ключ виджета (JIVO_WIDGET_ID). Включение на боевом — только с разрешения владельца. |
## 3. Что уже есть в портале (задел, ничего строить заново не надо)
- **Виджет Jivo встроен** в личный кабинет (компонент JivoWidget, июнь 2026, G7-A). Спит, пока не задан ключ.
- **Раздел «Помощь»**: форма заявки в поддержку + почта (support_requests) — останется запасным каналом.
- **Машинный ключ ИИ** (`lpimp_…`, дверь G7-B): готовый безопасный способ для ИИ смотреть данные конкретного клиента — пригодится на этапе 2 (личные ответы).
- **Механизм экскурсий** (WelcomeTour): подсветка элементов с пояснениями — переиспользуем для кнопки «Показать».
- **Плитка «Внешние сервисы»** в админке уже следит, жив ли Jivo (проба JivoLivenessProbe).
- **Руководство по постройке** своего ИИ-агента поддержки: `вебмастер-исходники-perplexity/Б3-ии-техподдержка.md` (архитектура: база знаний + поиск + ответ, борьба с выдумками, эскалация, метрики).
## 4. Главные факты про Jivo (исследование 02.07.2026)
- **Чат-платформа**: виджет на сайте, каналы Telegram/VK/WhatsApp/почта, приложение оператора на телефоне. Бесплатно до 2 операторов (без автоприглашений и WhatsApp); профессиональный ~1 342 ₽/мес, корпоративный ~3 142 ₽/мес за оператора.
- **Готовый «ИИ-оператор» Jivo**: 12 990 ₽/мес, 2 000 диалогов, проба 7 дней. База знаний: файлы (до 5×30 МБ), вопрос-ответ пары (до 50), текст «о компании». Отвечает только по базе, личных данных клиента не видит, при незнании передаёт человеку. Модель — российская (у «ИИ-ассистента» официально GigaChat), данные в РФ.
- **Bot API (наш путь)**: Jivo шлёт сообщение клиента на наш адрес → у нас 3 сек на «принял» и ~15 сек на ответ, иначе диалог уходит живому оператору. Ответ бота: текст или «позови человека». Доступен на корпоративном тарифе, подключение по письму.
- **152-ФЗ**: Jivo заявляет хранение данных в РФ. В нашу политику конфиденциальности при включении чата добавить упоминание Jivo.
## 5. Целевая картинка (как это будет работать)
1. Клиент в личном кабинете нажимает кнопку чата (Jivo) и пишет: «а что такое проект?»
2. Jivo мгновенно пересылает вопрос нашему серверу.
3. Наш бот находит нужный кусок в клиентской инструкции и за 2–5 секунд отвечает: «Проект — это… Создаётся для…» + кнопка **«Показать на портале»**.
4. Клик по кнопке → портал открывает форму создания проекта и по шагам подсвечивает поля: «здесь название… здесь источник… здесь лимит».
5. Если бот не знает ответа или клиент просит человека — диалог передаётся владельцу (в приложение оператора Jivo на телефон).
6. Каждый диалог сохраняется у нас — по ним пополняем инструкцию и видим, чего клиентам не хватает.
## 6. Следующие шаги
- [x] Закрыть О-1 (мозг бота) — YandexGPT Lite (решение 8).
- [x] Согласовать дизайн целиком (куски 1–4 приняты владельцем 02.07.2026) → спека
`docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md`.
- [x] Ревью спеки владельцем + коммит протокола и спеки (`23239faa`).
- [x] План реализации (`docs/superpowers/plans/2026-07-02-jivo-bot-core.md`, `fcc93879`).
- [x] **Ядро бота ПОСТРОЕНО** (02.07.2026, worktree `jivo-bot-core`, НЕ прод): приём из Jivo
(`/api/webhook/jivo/<секрет>`) → поиск по инструкции → ответ YandexGPT → эскалация
человеку → журнал; 3 статьи `resources/help/`; ночная переиндексация 04:30 МСК;
тест-сторож скорости (наша часть < 0,5 сек). Поиск — встроенный полнотекстовый
PostgreSQL вместо pgvector (быстрее, без новых деталей; спека поправлена).
- [x] Этап 3 — экскурсии (каталог, `?tour=`, кнопка «Показать») — построены 03.07.2026, worktree.
- [ ] Выкат на боевой — только с разрешения владельца: env-ключи (секрет вебхука, ключ
YandexGPT), отдельный обработчик очереди `bot`, тариф Jivo + письмо про Bot API (О-2).
- [ ] Правило «фича не готова без обновления инструкции» → CLAUDE.md через плагин (решение 6).
- [ ] Отдельно решить О-3 — включать ли бесплатный чат уже сейчас для разведки вопросов.
- [ ] О-2 — подтвердить корпоративный тариф Jivo перед подключением бота.
---
*Журнал обновлений: 02.07.2026 — протокол заведён, зафиксированы решения 1–7, исследование Jivo, открытые вопросы О-1…О-3. Позже в тот же день: решение 8 (YandexGPT Lite), дизайн согласован кусками 1–4, спека записана. Ещё позже: ядро бота построено по плану (14 задач TDD, worktree), поиск FTS вместо pgvector, спека синхронизирована.*