2026-07-02 21:00:40 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Models\KnowledgeChunk;
|
|
|
|
|
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('какой у меня баланс?');
|
|
|
|
|
|
2026-07-12 11:30:33 +03:00
|
|
|
// Текст именно стоп-эскалации («лучше разберёт живой специалист»), а не «в инструкции
|
|
|
|
|
// нет ответа»: до фикса 12.07.2026 вопрос проскакивал стоп-гейт и эскалировал лишь
|
|
|
|
|
// пустым поиском. Эскалация просит телефон (правило владельца), а не зовёт оператора.
|
2026-07-02 21:00:40 +03:00
|
|
|
expect($answer->escalate)->toBeTrue()
|
2026-07-12 11:30:33 +03:00
|
|
|
->and($answer->text)->toContain('лучше разберёт живой специалист')
|
|
|
|
|
->and($answer->text)->toContain('номер телефона');
|
2026-07-12 09:30:53 +03:00
|
|
|
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()
|
2026-07-12 11:30:33 +03:00
|
|
|
->and($answer->text)->toContain('лучше разберёт живой специалист');
|
2026-07-02 21:00:40 +03:00
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 10:15:09 +03:00
|
|
|
it('вопрос-продолжение: история разговора спасает пустой поиск и уходит в LLM', function () {
|
|
|
|
|
// «а можно самому, руками?» сам по себе не находит ничего — с прошлой репликой
|
|
|
|
|
// клиента поиск должен найти статью, а LLM получить переписку целиком.
|
|
|
|
|
KnowledgeChunk::create([
|
|
|
|
|
'source_path' => 'help/k.md', 'title' => 'Конкурентное поле', 'tour' => null,
|
|
|
|
|
'topics' => 'конкурентное поле, добавить вручную', 'chunk_index' => 0,
|
|
|
|
|
'content' => 'Конкурентное поле: конкурентов можно добавить вручную бесплатно.',
|
|
|
|
|
]);
|
|
|
|
|
Http::fake([
|
|
|
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
|
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Да, можно вручную.']]]],
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$history = [
|
|
|
|
|
['role' => 'user', 'text' => 'что за конкурентное поле?'],
|
|
|
|
|
['role' => 'assistant', 'text' => 'Конкурентное поле — раздел, где ИИ находит конкурентов.'],
|
|
|
|
|
];
|
|
|
|
|
$answer = app(BotAnswerService::class)->answer('а можно самому, руками?', $history);
|
|
|
|
|
|
|
|
|
|
expect($answer->escalate)->toBeFalse()
|
|
|
|
|
->and($answer->text)->toContain('Да, можно вручную');
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
$texts = array_column($request['messages'], 'text');
|
|
|
|
|
|
|
|
|
|
return in_array('что за конкурентное поле?', $texts, true)
|
|
|
|
|
&& end($texts) === 'а можно самому, руками?';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 10:24:01 +03:00
|
|
|
it('LLM ответил «в инструкции этого нет» → честная эскалация БЕЗ кнопки экскурсии', function () {
|
|
|
|
|
// Живой урок 12.07.2026: «а что такое объединить дубли?» → слабое совпадение поиска
|
|
|
|
|
// («что», «такое») → LLM честно сказал «В инструкции этого нет», а мы приляпали
|
|
|
|
|
// кнопку «Показать на портале». Незнание = эскалация, без кнопок.
|
|
|
|
|
config()->set('services.jivo_bot.tours_enabled', true);
|
|
|
|
|
Http::fake([
|
|
|
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
|
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'В инструкции этого нет.']]]],
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$answer = app(BotAnswerService::class)->answer('что такое объединить дубли?');
|
|
|
|
|
|
|
|
|
|
expect($answer->escalate)->toBeTrue()
|
|
|
|
|
->and($answer->text)->not->toContain('Показать на портале')
|
2026-07-12 11:30:33 +03:00
|
|
|
->and($answer->text)->toContain('в моей инструкции нет ответа')
|
|
|
|
|
->and($answer->text)->toContain('номер телефона');
|
2026-07-12 10:24:01 +03:00
|
|
|
});
|
|
|
|
|
|
2026-07-12 11:02:23 +03:00
|
|
|
it('вопрос про эксклюзивность заявок → эскалация без похода в LLM', function () {
|
|
|
|
|
Http::fake();
|
|
|
|
|
|
|
|
|
|
$variants = [
|
|
|
|
|
'а заявки эксклюзивные или как?',
|
|
|
|
|
'вы эти лиды кому-то ещё продаёте?',
|
|
|
|
|
'эти заявки только мои или вы их ещё кому-то отдаёте?',
|
|
|
|
|
'заявки достаются только мне?',
|
|
|
|
|
'вы одну заявку нескольким продаёте?',
|
|
|
|
|
];
|
|
|
|
|
foreach ($variants as $q) {
|
|
|
|
|
expect(app(BotAnswerService::class)->answer($q)->escalate)->toBeTrue();
|
|
|
|
|
}
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('«перенести МОИ заявки из другой CRM» → это про функцию импорта, а не личные данные', function () {
|
|
|
|
|
// Живой урок 12.07.2026: стоп-тема ловила «мои заявки» и уводила к человеку вопрос
|
|
|
|
|
// про импорт. Слова «перенести/импорт» — признак вопроса «как это делается».
|
|
|
|
|
KnowledgeChunk::create([
|
|
|
|
|
'source_path' => 'help/i.md', 'title' => 'Импорт своих заявок из CSV', 'tour' => null,
|
|
|
|
|
'topics' => 'импорт, перенести заявки, амоцрм', 'chunk_index' => 0,
|
|
|
|
|
'content' => 'Базу заявок из другой системы можно перенести: раздел «Импорт данных», файл CSV.',
|
|
|
|
|
]);
|
|
|
|
|
Http::fake([
|
|
|
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
|
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Да, через «Импорт данных» — файл CSV.']]]],
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$answer = app(BotAnswerService::class)->answer('можно перенести мои заявки из амоцрм?');
|
|
|
|
|
|
|
|
|
|
expect($answer->escalate)->toBeFalse()
|
|
|
|
|
->and($answer->text)->toContain('Импорт данных');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-02 21:00:40 +03:00
|
|
|
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();
|
|
|
|
|
});
|