diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index 2e62372d..c0c82c68 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -13,7 +13,7 @@ namespace App\Services\Bot; class BotAnswerService { /** Личные данные, деньги конкретного клиента, скидки, юр-темы, просьба человека. */ - private const STOP_PATTERN = '/(мо[йяеи]\s+(баланс|счет|счёт|деньг|проект|заявк|сделк)|у меня (на )?(балансе|счете|счёте)|скидк|оператор|человек|менеджер|жалоб|претензи|юрист|договор|возврат денег)/iu'; + private const STOP_PATTERN = '/(мо[йяеи]\s+(баланс|сч[её]т|деньг|проект|заявк|сделк)|у меня\s+(?:\S+\s+){0,3}(баланс|сч[её]т|деньг|проект|заявк|сделк)|скидк|оператор|человек|менеджер|жалоб|претензи|юрист|договор|возврат денег)/iu'; private const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист — передаю ему диалог. Он ответит здесь же.'; diff --git a/app/app/Services/Bot/KnowledgeSearch.php b/app/app/Services/Bot/KnowledgeSearch.php index a26e5be4..613d0a31 100644 --- a/app/app/Services/Bot/KnowledgeSearch.php +++ b/app/app/Services/Bot/KnowledgeSearch.php @@ -24,13 +24,24 @@ class KnowledgeSearch return []; } + // Строгий проход (все слова через И); пусто — мягкий запасной через ИЛИ: + // websearch_to_tsquery требует ВСЕ слова вопроса, а клиент пишет «зачем он нужен» — + // слова, которых в статьях нет (живой баг 12.07.2026). + $strict = "websearch_to_tsquery('russian', ?)"; + $soft = "replace(plainto_tsquery('russian', ?)::text, '&', '|')::tsquery"; + + $hits = $this->run($strict, $question, $limit); + + return $hits !== [] ? $hits : $this->run($soft, $question, $limit); + } + + /** @return list */ + private function run(string $tsquery, string $question, int $limit): array + { /** @var Collection $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]) + ->selectRaw("knowledge_chunks.*, ts_rank(search_tsv, {$tsquery}) AS rank", [$question]) + ->whereRaw("search_tsv @@ {$tsquery}", [$question]) ->orderByDesc('rank') ->limit($limit) ->get(); diff --git a/app/tests/Feature/Bot/BotAnswerServiceTest.php b/app/tests/Feature/Bot/BotAnswerServiceTest.php index 96a3d11a..28171b4c 100644 --- a/app/tests/Feature/Bot/BotAnswerServiceTest.php +++ b/app/tests/Feature/Bot/BotAnswerServiceTest.php @@ -27,8 +27,28 @@ it('стоп-тема (мой баланс) → эскалация без пох $answer = app(BotAnswerService::class)->answer('какой у меня баланс?'); + // Текст именно стоп-эскалации («передаю ему диалог»), а не «в инструкции нет ответа»: + // до фикса 12.07.2026 вопрос проскакивал стоп-гейт и эскалировал лишь пустым поиском. expect($answer->escalate)->toBeTrue() - ->and($answer->text)->toContain('специалисту'); + ->and($answer->text)->toContain('передаю ему диалог'); + Http::assertNothingSent(); +}); + +it('стоп-тема со словами между «у меня» и «баланс» → эскалация ДО поиска и LLM', function () { + // Живой баг 12.07.2026: «какой у меня сейчас баланс?» проскакивал мимо стоп-темы, + // поиск находил статью про пополнение и бот отвечал общими словами вместо передачи + // человеку. Статья про баланс в базе ЕСТЬ — эскалация обязана сработать раньше поиска. + KnowledgeChunk::create([ + 'source_path' => 'help/b.md', 'title' => 'Как пополнить баланс', 'tour' => 'top-up-balance', + 'topics' => 'пополнить, баланс', 'chunk_index' => 0, + 'content' => 'Пополнить баланс: раздел Биллинг, кнопка Пополнить.', + ]); + Http::fake(); + + $answer = app(BotAnswerService::class)->answer('какой у меня сейчас баланс?'); + + expect($answer->escalate)->toBeTrue() + ->and($answer->text)->toContain('передаю ему диалог'); Http::assertNothingSent(); }); diff --git a/app/tests/Feature/Bot/KnowledgeSearchTest.php b/app/tests/Feature/Bot/KnowledgeSearchTest.php index ab21cad1..5937e545 100644 --- a/app/tests/Feature/Bot/KnowledgeSearchTest.php +++ b/app/tests/Feature/Bot/KnowledgeSearchTest.php @@ -35,6 +35,15 @@ it('находит по синонимам из topics («закинуть де ->and($hits[0]->title)->toBe('Как пополнить баланс'); }); +it('находит статью, даже если часть слов вопроса в текстах отсутствует', function () { + // websearch_to_tsquery связывает слова через И: слово «нужен» в статье не встречается, + // строгий запрос даёт пусто — нужен мягкий запасной поиск через ИЛИ (живой баг 12.07.2026). + $hits = app(KnowledgeSearch::class)->search('Что такое проект и зачем он нужен?', 3); + + expect($hits)->not->toBeEmpty() + ->and($hits[0]->title)->toBe('Что такое проект'); +}); + it('на вопрос не по теме возвращает пусто', function () { expect(app(KnowledgeSearch::class)->search('какая погода в москве', 3))->toBeEmpty(); }); diff --git a/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-cabinet-color-setup.png b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-cabinet-color-setup.png new file mode 100644 index 00000000..7e351bc0 Binary files /dev/null and b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-cabinet-color-setup.png differ diff --git a/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-widget-01-portal-chat-open.png b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-widget-01-portal-chat-open.png new file mode 100644 index 00000000..331e0bef Binary files /dev/null and b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-widget-01-portal-chat-open.png differ diff --git a/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-widget-02-two-way-reply.png b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-widget-02-two-way-reply.png new file mode 100644 index 00000000..85379711 Binary files /dev/null and b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-widget-02-two-way-reply.png differ diff --git a/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-pole.png b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-pole.png new file mode 100644 index 00000000..9c860e6c Binary files /dev/null and b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-pole.png differ diff --git a/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step1.png b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step1.png new file mode 100644 index 00000000..35ad848b Binary files /dev/null and b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step1.png differ diff --git a/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step2-fixed.png b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step2-fixed.png new file mode 100644 index 00000000..28694dd4 Binary files /dev/null and b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step2-fixed.png differ diff --git a/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step2.png b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step2.png new file mode 100644 index 00000000..7a2a9e03 Binary files /dev/null and b/docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/tour-step2.png differ diff --git a/ПРОТОКОЛ-ии-дживосайт.md b/ПРОТОКОЛ-ии-дживосайт.md index a9bcab19..4ac3ee62 100644 --- a/ПРОТОКОЛ-ии-дживосайт.md +++ b/ПРОТОКОЛ-ии-дживосайт.md @@ -23,8 +23,8 @@ | № | Вопрос | Варианты / заметки | |---|---|---| | О-1 | ~~Какой ИИ-«мозг»~~ | **ЗАКРЫТ 02.07.2026** → решение 8: YandexGPT Lite. | -| О-2 | Тариф Jivo | Bot API официально доступен на корпоративном тарифе (~3 142 ₽/мес за оператора). Подтвердить готовность платить, когда дойдём до подключения. Подключение своего бота — по письму в поддержку Jivo (info@jivosite.com), автоматической кнопки нет. | -| О-3 | Когда включаем бесплатный чат Jivo (без бота, для разведки вопросов) | Код в портале готов, нужен только ключ виджета (JIVO_WIDGET_ID). Включение на боевом — только с разрешения владельца. | +| О-2 | Тариф Jivo | Bot API официально доступен на корпоративном тарифе (~3 142 ₽/мес за оператора). Подтвердить готовность платить, когда дойдём до подключения. Подключение своего бота — по письму в поддержку Jivo (info@jivosite.com), автоматической кнопки нет. Сейчас на аккаунте идёт 14-дневная проба «Профессиональной» версии (до ~26.07.2026). | +| О-3 | Когда включаем бесплатный чат Jivo (без бота, для разведки вопросов) | **ПОЧТИ ЗАКРЫТ 12.07.2026:** аккаунт зарегистрирован (sasha261185@yandex.ru, оператор «Александра»), ключ виджета получен — `zofyNxmnRP`, канал настроен и проверен на стенде в обе стороны. Осталось включить на боевом — только с разрешения владельца (пересборка фронта + CSP, см. журнал 12.07). | ## 3. Что уже есть в портале (задел, ничего строить заново не надо) @@ -73,3 +73,18 @@ --- *Журнал обновлений: 02.07.2026 — протокол заведён, зафиксированы решения 1–7, исследование Jivo, открытые вопросы О-1…О-3. Позже в тот же день: решение 8 (YandexGPT Lite), дизайн согласован кусками 1–4, спека записана. Ещё позже: ядро бота построено по плану (14 задач TDD, worktree), поиск FTS вместо pgvector, спека синхронизирована.* + +*12.07.2026 — владелец зарегистрировал аккаунт Jivo (sasha261185@yandex.ru), Claude настроил кабинет: +канал переименован в «Портал Лидерра — lk.liderra.ru», цвет окна чата — фирменный Teal `#0F6E56`, +ключ виджета — **`zofyNxmnRP`** (код: ``). +Живая проверка на стенде :8010 (worktree, `VITE_JIVO_WIDGET_ID` в .env + пересборка фронта): +пузырёк чата появился, сообщение с портала дошло в кабинет Jivo (канал подписан правильно), +ответ оператора вернулся в чат на портале — двусторонняя связь работает. Скриншоты — +`docs/superpowers/findings/2026-07-12-jivo-bot-live-demo/jivo-*.png`. Для включения на боевом +lk.liderra.ru нужно (по разрешению владельца): 1) пересборка фронта из канон-main с +`VITE_JIVO_WIDGET_ID=zofyNxmnRP` (ключ вшивается при сборке) и замена `public/build`; +2) добавить в CSP nginx (`lk`) домены Jivo: `script-src`/`connect-src` `code.jivo.ru +code.jivosite.com *.jivosite.com wss://*.jivosite.com` (CSP живёт только на проде, не в git); +3) на телефон владельца — приложение Jivo (вход sasha261185@yandex.ru), чтобы отвечать клиентам. +Аккаунт на 14-дневной пробе «Профессиональной» версии; после неё — бесплатный план (до 2 операторов) +либо корпоративный для Bot API (О-2).*