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(); +});