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, ]); }); it('отвечает вошедшему клиенту по его карточке фактов', function () { KnowledgeChunk::create([ 'source_path' => 'help/balance.md', 'title' => 'Баланс и списания', 'tour' => null, 'topics' => 'баланс, списание, пополнение', 'chunk_index' => 0, 'content' => 'Баланс — это сумма на вашем счёте в Лидерре, из неё списываются заявки.', ]); // Модель отвечает по делу ТОЛЬКО если увидела карточку фактов в системном промпте — // так тест доказывает, что карточка реально дошла до модели, а не потерялась по пути. Http::fake(function (Request $request) { $systemPrompt = (string) ($request['messages'][0]['text'] ?? ''); $hasFacts = str_contains($systemPrompt, 'ДАННЫЕ ЭТОГО КЛИЕНТА'); return Http::response([ 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => $hasFacts ? 'Ваш баланс: 3 400 ₽ ≈ 40 заявок.' : 'В инструкции этого нет.', ]]]], ]); }); $facts = "ДАННЫЕ ЭТОГО КЛИЕНТА (посчитаны только что):\nБаланс: 3 400 ₽ ≈ 40 заявок."; $answer = app(BotAnswerService::class)->answer('какой у меня баланс?', [], true, $facts); // Сторож AnswerGuard режет числа, которых нет в $context, — «3 400 ₽» пройдёт только // если карточка реально попала в $context (а не потерялась где-то по пути). expect($answer->escalate)->toBeFalse() ->and($answer->text)->toContain('3 400 ₽'); }); it('гостю с лендинга предлагает войти в кабинет, а не зовёт специалиста', function () { Http::fake(); $answer = app(BotAnswerService::class)->answer('сколько у меня на балансе?', [], false, null); expect($answer->escalate)->toBeFalse() ->and($answer->text)->toBe(BotAnswerService::LOGIN_TEXT); Http::assertNothingSent(); });