Files
portal/app/tests/Feature/Bot/BotAnswerServiceTest.php
T

194 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 () {
// Правило изменено 13.07.2026: личный вопрос теперь НЕ уходит к живому специалисту —
// это дорого и медленно. Вошедшему клиенту отвечает карточка фактов (см.
// PersonalAnswersTest); а здесь answer() зовётся по-старому, без карточки — то есть
// как гость с лендинга: специалиста звать незачем, зовём в кабинет.
Http::fake();
$answer = app(BotAnswerService::class)->answer('какой у меня баланс?');
expect($answer->escalate)->toBeFalse()
->and($answer->text)->toBe(BotAnswerService::LOGIN_TEXT);
Http::assertNothingSent();
});
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,
'content' => 'Пополнить баланс: раздел Биллинг, кнопка Пополнить.',
]);
Http::fake();
$answer = app(BotAnswerService::class)->answer('какой у меня сейчас баланс?');
expect($answer->escalate)->toBeFalse()
->and($answer->text)->toBe(BotAnswerService::LOGIN_TEXT);
Http::assertNothingSent();
});
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) === 'а можно самому, руками?';
});
});
it('LLM ответил «в инструкции этого нет» → честная эскалация БЕЗ кнопки экскурсии', function () {
// Живой урок 12.07.2026: «а что такое объединить дубли?» → слабое совпадение поиска
// («что», «такое») → LLM честно сказал «В инструкции этого нет», а мы приляпали
// кнопку «Показать на портале». Незнание = эскалация, без кнопок.
config()->set('services.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('Показать на портале')
->and($answer->text)->toContain('в моей инструкции нет ответа')
->and($answer->text)->toContain('номер телефона');
});
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('Импорт данных');
});
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.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.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();
});