feat(bot): BotAnswerService — стоп-темы, строгий промпт, tour-ссылка под флагом
This commit is contained in:
@@ -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,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\KnowledgeChunk;
|
||||
use App\Services\Bot\BotAnswer;
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user