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(); });