Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 726c682d2e | |||
| 7ea084d01f | |||
| 85c7c9b53c | |||
| 4ad2c065fc | |||
| 4afa228f15 | |||
| 2f9d7743ec | |||
| 94e5828fbc | |||
| 6841492226 | |||
| edf98d9ace | |||
| c90f721978 | |||
| 726aeb716a | |||
| 7a18dae0ca | |||
| 335bf4c3a8 | |||
| e2dfd22471 | |||
| edbfd3e993 | |||
| 4cab703b82 | |||
| 3a724fb8ef | |||
| 508d8cc1d5 | |||
| b04bb4ecf3 | |||
| e8e7332101 | |||
| 9f8ded5b77 | |||
| e01bcca751 | |||
| aa3bf3cbed | |||
| e3b58f2c2c | |||
| f606a06155 | |||
| b4ef5830e3 |
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
/**
|
||||
* Мозг ответа (спека §§4–5,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
|
||||
Ты — консультант техподдержки портала Лидерра (лиды для бизнеса). Отвечай кратко
|
||||
(2–5 предложений), простым русским языком, дружелюбно и на «вы».
|
||||
СТРОГИЕ ПРАВИЛА: отвечай ТОЛЬКО по приведённым ниже фрагментам инструкции;
|
||||
если ответа в них нет — скажи честно «в инструкции этого нет». Ничего не выдумывай.
|
||||
Не обещай скидок, цен и сроков, которых нет в фрагментах. Не отвечай на вопросы
|
||||
о данных конкретного клиента (баланс, его проекты) — предложи позвать специалиста.
|
||||
|
||||
Фрагменты инструкции:
|
||||
|
||||
{$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));
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Что такое проект
|
||||
tour: create-project
|
||||
topics: создать проект, заявка на лиды, источник, сайт конкурента, лимит заявок, новый проект
|
||||
---
|
||||
|
||||
Проект — это ваша заявка на поток клиентов. Вы указываете источник (например, сайт,
|
||||
похожий на ваш бизнес) и сколько заявок в день хотите получать — а система начинает
|
||||
присылать вам заявки с контактами.
|
||||
|
||||
Как создать: раздел «Проекты» → кнопка «Создать проект». Понадобится указать название,
|
||||
источник и дневной лимит заявок. После создания проект начинает работать не сразу —
|
||||
обычно в течение суток.
|
||||
|
||||
Заявки из проекта появляются в разделе «Сделки» — с телефоном, источником и статусом.
|
||||
Проект можно поставить на паузу или изменить лимит в любой момент.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Как пополнить баланс
|
||||
tour: top-up-balance
|
||||
topics: пополнить, закинуть деньги, оплата, счёт, платёж, банковская карта, безнал, пополнение баланса
|
||||
---
|
||||
|
||||
Пополнить баланс: раздел «Биллинг» → кнопка «Пополнить». Доступна оплата по счёту
|
||||
для юридических лиц и ИП: система выставит PDF-счёт, после оплаты деньги зачислятся,
|
||||
а акт придёт на почту.
|
||||
|
||||
Зачисление по счёту происходит после подтверждения оплаты. Баланс и историю всех
|
||||
операций видно в разделе «Биллинг».
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -2273,3 +2273,22 @@ srv
|
||||
поставщиковых
|
||||
пулер
|
||||
пуло
|
||||
|
||||
gzk
|
||||
noeviction
|
||||
автобэкапы
|
||||
ВТБ
|
||||
генерится
|
||||
деплоить
|
||||
затыков
|
||||
локал
|
||||
локалку
|
||||
ОКПО
|
||||
онли
|
||||
преflight
|
||||
Сбере
|
||||
синхрона
|
||||
golive
|
||||
jivo
|
||||
дживо
|
||||
gigachat
|
||||
|
||||
+11
-1
@@ -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
@@ -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, спека синхронизирована.*
|
||||
Reference in New Issue
Block a user