From e9dbb3f976ff5145e19feea3b4c7700ef871f19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 2 Jul 2026 21:00:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(bot):=20BotAnswerService=20=E2=80=94=20?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BF-=D1=82=D0=B5=D0=BC=D1=8B,=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B3=D0=B8=D0=B9=20=D0=BF=D1=80=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D1=82,=20tour-=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D1=84=D0=BB=D0=B0=D0=B3=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/Services/Bot/BotAnswer.php | 15 ++++ app/app/Services/Bot/BotAnswerService.php | 68 +++++++++++++++ .../Feature/Bot/BotAnswerServiceTest.php | 86 +++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 app/app/Services/Bot/BotAnswer.php create mode 100644 app/app/Services/Bot/BotAnswerService.php create mode 100644 app/tests/Feature/Bot/BotAnswerServiceTest.php diff --git a/app/app/Services/Bot/BotAnswer.php b/app/app/Services/Bot/BotAnswer.php new file mode 100644 index 00000000..71230bb6 --- /dev/null +++ b/app/app/Services/Bot/BotAnswer.php @@ -0,0 +1,15 @@ + $matchedChunkIds */ +class BotAnswer +{ + public function __construct( + public readonly string $text, + public readonly bool $escalate, + public readonly array $matchedChunkIds = [], + ) {} +} diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php new file mode 100644 index 00000000..2e62372d --- /dev/null +++ b/app/app/Services/Bot/BotAnswerService.php @@ -0,0 +1,68 @@ +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 = <<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)); + } +} diff --git a/app/tests/Feature/Bot/BotAnswerServiceTest.php b/app/tests/Feature/Bot/BotAnswerServiceTest.php new file mode 100644 index 00000000..6fcbd8d1 --- /dev/null +++ b/app/tests/Feature/Bot/BotAnswerServiceTest.php @@ -0,0 +1,86 @@ +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(); +});