diff --git a/app/app/Jobs/Bot/ProcessChatMessageJob.php b/app/app/Jobs/Bot/ProcessChatMessageJob.php index 6c486d32..34b639e6 100644 --- a/app/app/Jobs/Bot/ProcessChatMessageJob.php +++ b/app/app/Jobs/Bot/ProcessChatMessageJob.php @@ -8,6 +8,7 @@ use App\Mail\BotContactRequestMail; use App\Models\BotDialog; use App\Services\Bot\BotAnswer; use App\Services\Bot\BotAnswerService; +use App\Services\Bot\ClientFacts; use App\Services\Bot\ContactCapture; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -64,15 +65,33 @@ class ProcessChatMessageJob implements ShouldQueue ->values() ->all(); - // Экскурсия «Показать на портале» ведёт внутрь кабинета — гостю с лендинга она - // бесполезна, ему там нечего показывать. Откуда пишет клиент, знает журнал. - $fromPortal = BotDialog::query()->whereKey($this->inId)->value('source') !== 'landing'; + // Кто пишет: вошедший клиент или гость с лендинга. Вошедшему собираем карточку + // фактов (баланс, ступень, проекты, заявки) — цифры считает КОД, не модель. + $row = BotDialog::query()->whereKey($this->inId)->first(['source', 'user_id']); + $fromPortal = ($row?->source ?? 'portal') !== 'landing'; + $userId = $row?->user_id === null ? null : (int) $row->user_id; + + // Карточка — не критичный путь: если посчитать не удалось (клиент удалён из базы, + // база моргнула), клиент НЕ должен остаться без ответа — отвечаем как гостю, + // но отвечаем, а не роняем всю задачу (tries=1 — повтора не будет). + $facts = null; + if ($userId !== null) { + try { + $facts = app(ClientFacts::class)->card($userId); + } catch (\Throwable $e) { + Log::error('Не удалось собрать карточку фактов клиента для бота', [ + 'chat_id' => $this->chatId, + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + } + } // Номер в реплике = человек хочет, чтобы ему перезвонили. Принимаем ВСЕГДА, // просил бот телефон или нет (прогон диалогами 12.07.2026: контакт терялся). $answer = ($this->awaitsContact() || app(ContactCapture::class)->extractPhone($this->text) !== null) ? $this->captureContact($history, $fromPortal) - : app(BotAnswerService::class)->answer($this->text, $history, $fromPortal); + : app(BotAnswerService::class)->answer($this->text, $history, $fromPortal, $facts); BotDialog::create([ 'chat_id' => $this->chatId, diff --git a/app/tests/Feature/Bot/ProcessChatMessageJobTest.php b/app/tests/Feature/Bot/ProcessChatMessageJobTest.php index 9311e90c..99a47854 100644 --- a/app/tests/Feature/Bot/ProcessChatMessageJobTest.php +++ b/app/tests/Feature/Bot/ProcessChatMessageJobTest.php @@ -5,8 +5,12 @@ declare(strict_types=1); use App\Jobs\Bot\ProcessChatMessageJob; use App\Models\BotDialog; use App\Models\KnowledgeChunk; +use App\Services\Bot\BotAnswer; +use App\Services\Bot\BotAnswerService; +use App\Services\Bot\ClientFacts; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; uses(RefreshDatabase::class); @@ -56,10 +60,10 @@ it('happy path: ответ пишется в журнал (in+out) с latency', }); it('личный вопрос без карточки фактов: приглашение в кабинет в чате, журнал escalated=false', function () { - // Правило изменено 13.07.2026: ProcessChatMessageJob карточку фактов ClientFacts пока - // не передаёт (отдельная задача) — с точки зрения BotAnswerService это гость, и на - // личный вопрос он получает приглашение войти в кабинет, а не эскалацию к специалисту - // (см. BotContactCaptureTest, PersonalAnswersTest). + // chatMessage() пишет реплику ГОСТЯ (user_id не задан) — карточку фактов ClientFacts + // джоба спрашивает только у вошедшего клиента (см. тесты ниже про user_id), у гостя + // facts остаётся null и BotAnswerService приглашает войти в кабинет, а не зовёт + // специалиста (см. BotContactCaptureTest, PersonalAnswersTest). chatMessage('chat-2', 'какой у меня баланс?'); $out = BotDialog::where('direction', 'out')->firstOrFail(); @@ -110,3 +114,110 @@ it('джоба объявлена с queue=bot и timeout ≤ 12 сек', functi expect($job->queue)->toBe('bot') ->and($job->timeout)->toBeLessThanOrEqual(12); }); + +it('вошедший клиент (у строки журнала есть user_id): джоба берёт карточку у ClientFacts и передаёт её в BotAnswerService', function () { + $facts = new class + { + /** @var list */ + 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(); +});