diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index d3b09d96..5be248d2 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -120,6 +120,12 @@ class BotAnswerService public const UNKNOWN_TEXT = 'Честно — в моей инструкции нет ответа на этот вопрос. Оставьте, пожалуйста, ваш номер телефона — специалист свяжется с вами и ответит.'; + /** + * Личный вопрос от ГОСТЯ (карточки фактов нет). Звать живого человека ради цифры, + * которую клиент увидит сам, — дорого и глупо: честно отправляем в кабинет. + */ + public const LOGIN_TEXT = 'Ваши цифры — баланс, ступень, проекты и заявки — я показываю в личном кабинете. Войдите, пожалуйста, и спросите меня снова: отвечу по вашим данным.'; + /** Подтверждение после того, как клиент оставил телефон. */ public const CONTACT_TAKEN_TEXT = 'Спасибо! Передал ваш номер специалисту — он свяжется с вами в ближайшее время. Если есть ещё вопросы по порталу — спрашивайте, я на связи.'; @@ -147,8 +153,10 @@ class BotAnswerService * @param bool $fromPortal клиент пишет из кабинета (а не с лендинга). Экскурсия * «Показать на портале» ведёт внутрь кабинета — гостю * с лендинга она бесполезна, ему там нечего показывать. + * @param string|null $facts карточка фактов вошедшего клиента (ClientFacts::card). + * Null — пишет гость: личных цифр не знаем и не выдумываем. */ - public function answer(string $question, array $history = [], bool $fromPortal = true): BotAnswer + public function answer(string $question, array $history = [], bool $fromPortal = true, ?string $facts = null): BotAnswer { // Приветствие, «спасибо», «ты бот?» — отвечаем сами и не просим телефон. foreach (self::SMALL_TALK as $pattern => $reply) { @@ -166,9 +174,15 @@ class BotAnswerService $isAccess = preg_match(self::ACCESS_PATTERN, $question) === 1; $wantsHuman = ! $isAccess && preg_match(self::SUMMON_PATTERN, $question) === 1; - $isStop = $wantsHuman - || preg_match(self::HARD_STOP_PATTERN, $question) === 1 - || (! $isHowTo && preg_match(self::PERSONAL_PATTERN, $question) === 1); + $isPersonal = ! $isHowTo && preg_match(self::PERSONAL_PATTERN, $question) === 1; + + // Личный вопрос вошедшего клиента бот теперь ОТВЕЧАЕТ сам — цифры даёт карточка. + // У гостя карточки нет: специалиста не зовём, зовём в кабинет. + if ($isPersonal && $facts === null) { + return new BotAnswer(self::LOGIN_TEXT, escalate: false); + } + + $isStop = $wantsHuman || preg_match(self::HARD_STOP_PATTERN, $question) === 1; if ($isStop) { return new BotAnswer(self::ESCALATE_TEXT, escalate: true); @@ -194,11 +208,13 @@ class BotAnswerService // Цены в статьях стоят метками ({{цена_подбора}} и т.п.) — подставляем ЖИВЫЕ // значения из настроек прямо сейчас: поменяли цену в админке — бот называет новую. $prices = app(LivePrices::class); - $context = implode("\n\n---\n\n", array_map( + $articles = implode("\n\n---\n\n", array_map( fn ($c) => "### {$c->title}\n".$prices->substitute((string) $c->content), $chunks )); + $context = $facts === null ? $articles : $facts."\n\n---\n\n".$articles; + $system = <<answer('какой у меня баланс?'); - // Текст именно стоп-эскалации («лучше разберёт живой специалист»), а не «в инструкции - // нет ответа»: до фикса 12.07.2026 вопрос проскакивал стоп-гейт и эскалировал лишь - // пустым поиском. Эскалация просит телефон (правило владельца), а не зовёт оператора. - expect($answer->escalate)->toBeTrue() - ->and($answer->text)->toContain('лучше разберёт живой специалист') - ->and($answer->text)->toContain('номер телефона'); + expect($answer->escalate)->toBeFalse() + ->and($answer->text)->toBe(BotAnswerService::LOGIN_TEXT); Http::assertNothingSent(); }); -it('стоп-тема со словами между «у меня» и «баланс» → эскалация ДО поиска и LLM', function () { +it('личный вопрос со словами между «у меня» и «баланс» без карточки → тоже приглашение в кабинет, ДО поиска и LLM', function () { // Живой баг 12.07.2026: «какой у меня сейчас баланс?» проскакивал мимо стоп-темы, // поиск находил статью про пополнение и бот отвечал общими словами вместо передачи - // человеку. Статья про баланс в базе ЕСТЬ — эскалация обязана сработать раньше поиска. + // человеку. Правило 13.07.2026 сменило адресата: без карточки фактов — не специалист, + // а кабинет (см. комментарий к тесту выше). Приглашение обязано сработать раньше поиска. KnowledgeChunk::create([ 'source_path' => 'help/b.md', 'title' => 'Как пополнить баланс', 'tour' => 'top-up-balance', 'topics' => 'пополнить, баланс', 'chunk_index' => 0, @@ -49,8 +50,8 @@ it('стоп-тема со словами между «у меня» и «бал $answer = app(BotAnswerService::class)->answer('какой у меня сейчас баланс?'); - expect($answer->escalate)->toBeTrue() - ->and($answer->text)->toContain('лучше разберёт живой специалист'); + expect($answer->escalate)->toBeFalse() + ->and($answer->text)->toBe(BotAnswerService::LOGIN_TEXT); Http::assertNothingSent(); }); diff --git a/app/tests/Feature/Bot/BotContactCaptureTest.php b/app/tests/Feature/Bot/BotContactCaptureTest.php index 10dc1744..df6e623a 100644 --- a/app/tests/Feature/Bot/BotContactCaptureTest.php +++ b/app/tests/Feature/Bot/BotContactCaptureTest.php @@ -45,14 +45,17 @@ function chatTurn(string $chatId, string $text): void (new ProcessChatMessageJob($chatId, (int) $row->id, $text))->handle(); } -it('эскалация просит телефон', function () { +it('личный вопрос без карточки фактов → приглашение в кабинет, телефон не просим', function () { + // Правило изменено 13.07.2026: ProcessChatMessageJob карточку фактов ClientFacts пока + // не передаёт (отдельная задача) — с точки зрения BotAnswerService это гость, и на + // личный вопрос он получает приглашение войти в кабинет, а не просьбу телефона. Mail::fake(); chatTurn('chat-c1', 'какой у меня баланс?'); $out = BotDialog::where('direction', 'out')->firstOrFail(); - expect($out->escalated)->toBeTrue() - ->and($out->message)->toContain('номер телефона'); + expect($out->escalated)->toBeFalse() + ->and($out->message)->toContain('личном кабинете'); Mail::assertNothingSent(); }); diff --git a/app/tests/Feature/Bot/PersonalAnswersTest.php b/app/tests/Feature/Bot/PersonalAnswersTest.php new file mode 100644 index 00000000..bed4097b --- /dev/null +++ b/app/tests/Feature/Bot/PersonalAnswersTest.php @@ -0,0 +1,66 @@ +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, + ]); +}); + +it('отвечает вошедшему клиенту по его карточке фактов', function () { + KnowledgeChunk::create([ + 'source_path' => 'help/balance.md', 'title' => 'Баланс и списания', 'tour' => null, + 'topics' => 'баланс, списание, пополнение', 'chunk_index' => 0, + 'content' => 'Баланс — это сумма на вашем счёте в Лидерре, из неё списываются заявки.', + ]); + + // Модель отвечает по делу ТОЛЬКО если увидела карточку фактов в системном промпте — + // так тест доказывает, что карточка реально дошла до модели, а не потерялась по пути. + Http::fake(function (Request $request) { + $systemPrompt = (string) ($request['messages'][0]['text'] ?? ''); + $hasFacts = str_contains($systemPrompt, 'ДАННЫЕ ЭТОГО КЛИЕНТА'); + + return Http::response([ + 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => $hasFacts + ? 'Ваш баланс: 3 400 ₽ ≈ 40 заявок.' + : 'В инструкции этого нет.', + ]]]], + ]); + }); + + $facts = "ДАННЫЕ ЭТОГО КЛИЕНТА (посчитаны только что):\nБаланс: 3 400 ₽ ≈ 40 заявок."; + $answer = app(BotAnswerService::class)->answer('какой у меня баланс?', [], true, $facts); + + // Сторож AnswerGuard режет числа, которых нет в $context, — «3 400 ₽» пройдёт только + // если карточка реально попала в $context (а не потерялась где-то по пути). + expect($answer->escalate)->toBeFalse() + ->and($answer->text)->toContain('3 400 ₽'); +}); + +it('гостю с лендинга предлагает войти в кабинет, а не зовёт специалиста', function () { + Http::fake(); + + $answer = app(BotAnswerService::class)->answer('сколько у меня на балансе?', [], false, null); + + expect($answer->escalate)->toBeFalse() + ->and($answer->text)->toBe(BotAnswerService::LOGIN_TEXT); + Http::assertNothingSent(); +}); diff --git a/app/tests/Feature/Bot/ProcessChatMessageJobTest.php b/app/tests/Feature/Bot/ProcessChatMessageJobTest.php index cfd53ab4..9311e90c 100644 --- a/app/tests/Feature/Bot/ProcessChatMessageJobTest.php +++ b/app/tests/Feature/Bot/ProcessChatMessageJobTest.php @@ -55,14 +55,16 @@ it('happy path: ответ пишется в журнал (in+out) с latency', ->and($out->matched_chunks)->not->toBeNull(); }); -it('эскалация: просит телефон в чате, журнал escalated=true', function () { - // Правило владельца 12.07.2026: живого оператора в чате нет — бот собирает контакт - // сам и отдаёт его поддержке письмом (см. BotContactCaptureTest). +it('личный вопрос без карточки фактов: приглашение в кабинет в чате, журнал escalated=false', function () { + // Правило изменено 13.07.2026: ProcessChatMessageJob карточку фактов ClientFacts пока + // не передаёт (отдельная задача) — с точки зрения BotAnswerService это гость, и на + // личный вопрос он получает приглашение войти в кабинет, а не эскалацию к специалисту + // (см. BotContactCaptureTest, PersonalAnswersTest). chatMessage('chat-2', 'какой у меня баланс?'); $out = BotDialog::where('direction', 'out')->firstOrFail(); - expect($out->message)->toContain('номер телефона') - ->and($out->escalated)->toBeTrue(); + expect($out->message)->toContain('личном кабинете') + ->and($out->escalated)->toBeFalse(); }); it('второе сообщение чата уходит в LLM вместе с историей первого', function () {