Files
portal/app/tests/Feature/Bot/BotAnswerServiceTest.php
T
Дмитрий 185df2d810 feat(jivo-bot): память разговора + статья про ручное добавление конкурентов
Живой урок владельца 12.07.2026 («а сам руками я могу?» — бот талдычил про ИИ за 300₽):
- ProcessJivoMessageJob: последние 6 реплик чата → история для LLM (хвосты
  «👉 Показать…» срезаются), только СВОЙ чат
- BotAnswerService/YandexGptClient: история между system и текущим вопросом;
  пустой поиск досыпается прошлой репликой клиента
- статья konkurentnoe-pole: ручное добавление конкурента/точек/источников — бесплатно,
  кнопка «+ Добавить вручную» (фича существует, бот о ней не знал)
- TDD: 3 новых теста, все 37 зелёные

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 10:15:09 +03:00

136 lines
6.7 KiB
PHP

<?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('какой у меня баланс?');
// Текст именно стоп-эскалации («передаю ему диалог»), а не «в инструкции нет ответа»:
// до фикса 12.07.2026 вопрос проскакивал стоп-гейт и эскалировал лишь пустым поиском.
expect($answer->escalate)->toBeTrue()
->and($answer->text)->toContain('передаю ему диалог');
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()
->and($answer->text)->toContain('передаю ему диалог');
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('просьба позвать человека → эскалация', 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();
});