2026-07-02 21:07:41 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
2026-07-13 07:04:38 +03:00
|
|
|
|
use App\Jobs\Bot\ProcessChatMessageJob;
|
2026-07-02 21:07:41 +03:00
|
|
|
|
use App\Models\BotDialog;
|
|
|
|
|
|
use App\Models\KnowledgeChunk;
|
2026-07-13 12:00:42 +03:00
|
|
|
|
use App\Services\Bot\BotAnswer;
|
|
|
|
|
|
use App\Services\Bot\BotAnswerService;
|
|
|
|
|
|
use App\Services\Bot\ClientFacts;
|
2026-07-02 21:07:41 +03:00
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
2026-07-13 12:00:42 +03:00
|
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-07-02 21:07:41 +03:00
|
|
|
|
|
|
|
|
|
|
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' => null,
|
|
|
|
|
|
'topics' => 'создать проект', 'chunk_index' => 0,
|
|
|
|
|
|
'content' => 'Проект — это заявка на поток клиентов.',
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-13 07:04:38 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Мимикрирует то, что делает ChatController: пишет реплику клиента в журнал и
|
|
|
|
|
|
* запускает джобу с её номером, чтобы история не включала текущий вопрос дважды.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function chatMessage(string $chatId, string $text): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$row = BotDialog::create([
|
|
|
|
|
|
'chat_id' => $chatId,
|
|
|
|
|
|
'direction' => 'in',
|
|
|
|
|
|
'message' => $text,
|
|
|
|
|
|
'created_at' => now(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
(new ProcessChatMessageJob($chatId, (int) $row->id, $text))->handle();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('happy path: ответ пишется в журнал (in+out) с latency', function () {
|
2026-07-02 21:07:41 +03:00
|
|
|
|
Http::fake([
|
|
|
|
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
|
|
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
|
|
|
|
|
|
]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-07-13 07:04:38 +03:00
|
|
|
|
chatMessage('chat-1', 'что такое проект?');
|
2026-07-02 21:07:41 +03:00
|
|
|
|
|
|
|
|
|
|
$out = BotDialog::where('direction', 'out')->firstOrFail();
|
|
|
|
|
|
expect(BotDialog::where('direction', 'in')->count())->toBe(1)
|
|
|
|
|
|
->and($out->latency_ms)->toBeGreaterThanOrEqual(0)
|
|
|
|
|
|
->and($out->escalated)->toBeFalse()
|
|
|
|
|
|
->and($out->matched_chunks)->not->toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-13 11:48:39 +03:00
|
|
|
|
it('личный вопрос без карточки фактов: приглашение в кабинет в чате, журнал escalated=false', function () {
|
2026-07-13 12:00:42 +03:00
|
|
|
|
// chatMessage() пишет реплику ГОСТЯ (user_id не задан) — карточку фактов ClientFacts
|
|
|
|
|
|
// джоба спрашивает только у вошедшего клиента (см. тесты ниже про user_id), у гостя
|
|
|
|
|
|
// facts остаётся null и BotAnswerService приглашает войти в кабинет, а не зовёт
|
|
|
|
|
|
// специалиста (см. BotContactCaptureTest, PersonalAnswersTest).
|
2026-07-13 07:04:38 +03:00
|
|
|
|
chatMessage('chat-2', 'какой у меня баланс?');
|
2026-07-02 21:07:41 +03:00
|
|
|
|
|
2026-07-13 07:04:38 +03:00
|
|
|
|
$out = BotDialog::where('direction', 'out')->firstOrFail();
|
2026-07-13 11:48:39 +03:00
|
|
|
|
expect($out->message)->toContain('личном кабинете')
|
|
|
|
|
|
->and($out->escalated)->toBeFalse();
|
2026-07-02 21:07:41 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-12 10:15:09 +03:00
|
|
|
|
it('второе сообщение чата уходит в LLM вместе с историей первого', function () {
|
|
|
|
|
|
// Живой урок 12.07.2026: «а сам руками я могу?» без памяти разговора — бессмыслица.
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
|
|
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Конечно.']]]],
|
|
|
|
|
|
]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-07-13 07:04:38 +03:00
|
|
|
|
chatMessage('chat-h', 'что такое проект?');
|
|
|
|
|
|
chatMessage('chat-h', 'а расскажи про проект подробнее');
|
2026-07-12 10:15:09 +03:00
|
|
|
|
|
|
|
|
|
|
$llmCalls = collect(Http::recorded())
|
|
|
|
|
|
->filter(fn ($pair) => str_contains($pair[0]->url(), 'llm.api.cloud.yandex.net'));
|
|
|
|
|
|
$lastMessages = collect($llmCalls->last()[0]['messages']);
|
|
|
|
|
|
|
|
|
|
|
|
expect($llmCalls)->toHaveCount(2)
|
|
|
|
|
|
->and($lastMessages->pluck('text'))->toContain('что такое проект?')
|
|
|
|
|
|
->and($lastMessages->pluck('role'))->toContain('assistant');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('история — только своего чата, чужие диалоги в LLM не утекают', function () {
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
|
|
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Ок.']]]],
|
|
|
|
|
|
]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-07-13 07:04:38 +03:00
|
|
|
|
chatMessage('chat-alien', 'что такое проект?');
|
|
|
|
|
|
chatMessage('chat-mine', 'расскажи про проект');
|
2026-07-12 10:15:09 +03:00
|
|
|
|
|
|
|
|
|
|
$last = collect(collect(Http::recorded())
|
|
|
|
|
|
->filter(fn ($pair) => str_contains($pair[0]->url(), 'llm.api.cloud.yandex.net'))
|
|
|
|
|
|
->last()[0]['messages']);
|
|
|
|
|
|
|
|
|
|
|
|
expect($last->pluck('text'))->not->toContain('что такое проект?');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-02 21:07:41 +03:00
|
|
|
|
it('джоба объявлена с queue=bot и timeout ≤ 12 сек', function () {
|
2026-07-13 07:04:38 +03:00
|
|
|
|
$job = new ProcessChatMessageJob('c', 1, 'q');
|
2026-07-02 21:07:41 +03:00
|
|
|
|
|
|
|
|
|
|
expect($job->queue)->toBe('bot')
|
|
|
|
|
|
->and($job->timeout)->toBeLessThanOrEqual(12);
|
|
|
|
|
|
});
|
2026-07-13 12:00:42 +03:00
|
|
|
|
|
|
|
|
|
|
it('вошедший клиент (у строки журнала есть user_id): джоба берёт карточку у ClientFacts и передаёт её в BotAnswerService', function () {
|
|
|
|
|
|
$facts = new class
|
|
|
|
|
|
{
|
|
|
|
|
|
/** @var list<int> */
|
|
|
|
|
|
public array $calledWith = [];
|
|
|
|
|
|
|
|
|
|
|
|
public function card(int $userId): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->calledWith[] = $userId;
|
|
|
|
|
|
|
|
|
|
|
|
return 'ДАННЫЕ ЭТОГО КЛИЕНТА: Баланс: 777 ₽.';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
app()->instance(ClientFacts::class, $facts);
|
|
|
|
|
|
|
|
|
|
|
|
$botAnswer = new class
|
|
|
|
|
|
{
|
|
|
|
|
|
public ?string $receivedFacts = 'UNSET';
|
|
|
|
|
|
|
|
|
|
|
|
public function answer(string $question, array $history = [], bool $fromPortal = true, ?string $facts = null): BotAnswer
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->receivedFacts = $facts;
|
|
|
|
|
|
|
|
|
|
|
|
return new BotAnswer('ответ', escalate: false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
app()->instance(BotAnswerService::class, $botAnswer);
|
|
|
|
|
|
|
|
|
|
|
|
$row = BotDialog::create([
|
|
|
|
|
|
'chat_id' => 'chat-user',
|
|
|
|
|
|
'direction' => 'in',
|
|
|
|
|
|
'message' => 'какой у меня баланс?',
|
|
|
|
|
|
'user_id' => 777,
|
|
|
|
|
|
'created_at' => now(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
(new ProcessChatMessageJob('chat-user', (int) $row->id, 'какой у меня баланс?'))->handle();
|
|
|
|
|
|
|
|
|
|
|
|
expect($facts->calledWith)->toBe([777])
|
|
|
|
|
|
->and($botAnswer->receivedFacts)->toContain('777 ₽');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('гость (у строки журнала user_id пуст): ClientFacts не вызывается, в BotAnswerService приходит facts=null', function () {
|
|
|
|
|
|
$facts = new class
|
|
|
|
|
|
{
|
|
|
|
|
|
public int $calls = 0;
|
|
|
|
|
|
|
|
|
|
|
|
public function card(int $userId): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->calls++;
|
|
|
|
|
|
|
|
|
|
|
|
return 'ЭТО НЕ ДОЛЖНО БЫТЬ ВЫЗВАНО У ГОСТЯ';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
app()->instance(ClientFacts::class, $facts);
|
|
|
|
|
|
|
|
|
|
|
|
$botAnswer = new class
|
|
|
|
|
|
{
|
|
|
|
|
|
public ?string $receivedFacts = 'UNSET';
|
|
|
|
|
|
|
|
|
|
|
|
public function answer(string $question, array $history = [], bool $fromPortal = true, ?string $facts = null): BotAnswer
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->receivedFacts = $facts;
|
|
|
|
|
|
|
|
|
|
|
|
return new BotAnswer('ответ', escalate: false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
app()->instance(BotAnswerService::class, $botAnswer);
|
|
|
|
|
|
|
|
|
|
|
|
chatMessage('chat-guest-facts', 'что такое проект?');
|
|
|
|
|
|
|
|
|
|
|
|
expect($facts->calls)->toBe(0)
|
|
|
|
|
|
->and($botAnswer->receivedFacts)->toBeNull();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('ClientFacts бросил исключение: клиент всё равно получает ответ (журнал out), ошибка уходит в лог', function () {
|
|
|
|
|
|
Log::spy();
|
|
|
|
|
|
|
|
|
|
|
|
app()->instance(ClientFacts::class, new class
|
|
|
|
|
|
{
|
|
|
|
|
|
public function card(int $userId): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new RuntimeException('база моргнула');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
|
'llm.api.cloud.yandex.net/*' => Http::response([
|
|
|
|
|
|
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Ок.']]]],
|
|
|
|
|
|
]),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$row = BotDialog::create([
|
|
|
|
|
|
'chat_id' => 'chat-boom',
|
|
|
|
|
|
'direction' => 'in',
|
|
|
|
|
|
'message' => 'что такое проект?',
|
|
|
|
|
|
'user_id' => 999,
|
|
|
|
|
|
'created_at' => now(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
(new ProcessChatMessageJob('chat-boom', (int) $row->id, 'что такое проект?'))->handle();
|
|
|
|
|
|
|
|
|
|
|
|
expect(BotDialog::where('chat_id', 'chat-boom')->where('direction', 'out')->exists())->toBeTrue();
|
|
|
|
|
|
|
|
|
|
|
|
Log::shouldHaveReceived('error')->once();
|
|
|
|
|
|
});
|