From 185df2d810f971986bdb3a905d45208d3af2a001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 12 Jul 2026 10:15:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(jivo-bot):=20=D0=BF=D0=B0=D0=BC=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B0=D0=B7=D0=B3=D0=BE=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20+=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=BA=D1=83=D1=80=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живой урок владельца 12.07.2026 («а сам руками я могу?» — бот талдычил про ИИ за 300₽): - ProcessJivoMessageJob: последние 6 реплик чата → история для LLM (хвосты «👉 Показать…» срезаются), только СВОЙ чат - BotAnswerService/YandexGptClient: история между system и текущим вопросом; пустой поиск досыпается прошлой репликой клиента - статья konkurentnoe-pole: ручное добавление конкурента/точек/источников — бесплатно, кнопка «+ Добавить вручную» (фича существует, бот о ней не знал) - TDD: 3 новых теста, все 37 зелёные Co-Authored-By: Claude Fable 5 --- app/app/Jobs/Bot/ProcessJivoMessageJob.php | 20 +++++++++- app/app/Services/Bot/BotAnswerService.php | 21 +++++++++- app/app/Services/Bot/YandexGptClient.php | 7 +++- app/resources/help/konkurentnoe-pole.md | 10 ++++- .../Feature/Bot/BotAnswerServiceTest.php | 30 ++++++++++++++ .../Feature/Bot/ProcessJivoMessageJobTest.php | 39 +++++++++++++++++++ 6 files changed, 122 insertions(+), 5 deletions(-) diff --git a/app/app/Jobs/Bot/ProcessJivoMessageJob.php b/app/app/Jobs/Bot/ProcessJivoMessageJob.php index b51d1c2f..2f55da7b 100644 --- a/app/app/Jobs/Bot/ProcessJivoMessageJob.php +++ b/app/app/Jobs/Bot/ProcessJivoMessageJob.php @@ -32,10 +32,28 @@ class ProcessJivoMessageJob implements ShouldQueue $this->onQueue('bot'); } + /** Сколько прошлых реплик чата отдаём боту как память разговора (3 пары вопрос-ответ). */ + private const HISTORY_LIMIT = 6; + public function handle(): void { $startedAt = hrtime(true); + // Память разговора — ДО записи текущего сообщения (иначе оно попадёт в историю). + // Служебные хвосты «👉 Показать на портале: …» из прошлых ответов LLM не нужны. + $history = BotDialog::query() + ->where('jivo_chat_id', $this->chatId) + ->orderByDesc('id') + ->limit(self::HISTORY_LIMIT) + ->get(['direction', 'message']) + ->reverse() + ->map(fn (BotDialog $d) => [ + 'role' => $d->direction === 'in' ? 'user' : 'assistant', + 'text' => trim((string) preg_replace('/\n*👉 Показать на портале:.*$/su', '', $d->message)), + ]) + ->values() + ->all(); + BotDialog::create([ 'jivo_chat_id' => $this->chatId, 'direction' => 'in', @@ -43,7 +61,7 @@ class ProcessJivoMessageJob implements ShouldQueue 'created_at' => now(), ]); - $answer = app(BotAnswerService::class)->answer($this->text); + $answer = app(BotAnswerService::class)->answer($this->text, $history); $jivo = app(JivoBotClient::class); $jivo->sendMessage($this->chatId, $this->clientId, $answer->text); diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index c0c82c68..511e1307 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -24,13 +24,30 @@ class BotAnswerService private readonly YandexGptClient $gpt, ) {} - public function answer(string $question): BotAnswer + /** + * @param list $history прошлые реплики этого чата + * (хронологически) — контекст для коротких вопросов-продолжений + */ + public function answer(string $question, array $history = []): BotAnswer { if (preg_match(self::STOP_PATTERN, $question) === 1) { return new BotAnswer(self::ESCALATE_TEXT, escalate: true); } $chunks = $this->search->search($question, 3); + if ($chunks === [] && $history !== []) { + // «а можно самому?» сам по себе пуст — досыпаем прошлую реплику клиента. + $lastUser = null; + foreach (array_reverse($history) as $m) { + if (($m['role'] ?? '') === 'user') { + $lastUser = (string) $m['text']; + break; + } + } + if ($lastUser !== null) { + $chunks = $this->search->search($lastUser.' '.$question, 3); + } + } if ($chunks === []) { return new BotAnswer(self::UNKNOWN_TEXT, escalate: true); } @@ -53,7 +70,7 @@ class BotAnswerService {$context} PROMPT; - $text = $this->gpt->complete($system, $question); + $text = $this->gpt->complete($system, $question, $history); if ($text === null) { return new BotAnswer(self::ESCALATE_TEXT, escalate: true); } diff --git a/app/app/Services/Bot/YandexGptClient.php b/app/app/Services/Bot/YandexGptClient.php index 0fe77641..4022da5b 100644 --- a/app/app/Services/Bot/YandexGptClient.php +++ b/app/app/Services/Bot/YandexGptClient.php @@ -14,7 +14,11 @@ use Illuminate\Support\Facades\Log; */ class YandexGptClient { - public function complete(string $systemPrompt, string $userText): ?string + /** + * @param list $history прошлые реплики чата + * (хронологически), между system и текущим вопросом + */ + public function complete(string $systemPrompt, string $userText, array $history = []): ?string { $cfg = (array) config('services.yandexgpt'); if (($cfg['api_key'] ?? '') === '' || ($cfg['folder_id'] ?? '') === '') { @@ -29,6 +33,7 @@ class YandexGptClient 'completionOptions' => ['stream' => false, 'temperature' => 0.2, 'maxTokens' => 500], 'messages' => [ ['role' => 'system', 'text' => $systemPrompt], + ...array_map(fn ($m) => ['role' => (string) $m['role'], 'text' => (string) $m['text']], $history), ['role' => 'user', 'text' => $userText], ], ]); diff --git a/app/resources/help/konkurentnoe-pole.md b/app/resources/help/konkurentnoe-pole.md index d2cf0a73..1df947d1 100644 --- a/app/resources/help/konkurentnoe-pole.md +++ b/app/resources/help/konkurentnoe-pole.md @@ -1,7 +1,7 @@ --- title: Конкурентное поле — автоподбор конкурентов tour: konkurentnoe-pole -topics: конкурентное поле, автоподбор, найти конкурентов, собрать конкурентов, собрать источники, изучение конкурента, откуда лиды, радар +topics: конкурентное поле, автоподбор, найти конкурентов, собрать конкурентов, собрать источники, изучение конкурента, откуда лиды, радар, добавить конкурента вручную, руками, самому, самостоятельно, без ИИ, бесплатно --- «Конкурентное поле» — раздел, где искусственный интеллект находит ваших конкурентов @@ -22,5 +22,13 @@ topics: конкурентное поле, автоподбор, найти ко задания встанут в очередь и выполнятся по очереди. Если на балансе не хватает на всю пачку, система подскажет: пополнить или уменьшить выборку. +Можно и без искусственного интеллекта, руками — это бесплатно. На экране «Конкурентного +поля» есть кнопка «+ Добавить вручную»: вписываете название конкурента, тип (местный или +федеральный), при желании описание и его точки — названия, сайты, ссылки на карточки +(2ГИС, Яндекс Карты). Такой конкурент появляется в поле рядом с найденными автоматически, +его карточку можно менять кнопкой «Изменить». Источники у конкурента тоже добавляются +вручную на его странице — «добавьте источник вручную». Платный ИИ-сбор нужен только когда +хотите, чтобы система сама нашла конкурентов или вскрыла их сайты и телефоны. + Из найденного источника в один клик создаётся проект — дальше он работает как обычный проект и приносит заявки в раздел «Сделки». diff --git a/app/tests/Feature/Bot/BotAnswerServiceTest.php b/app/tests/Feature/Bot/BotAnswerServiceTest.php index 28171b4c..29182cfe 100644 --- a/app/tests/Feature/Bot/BotAnswerServiceTest.php +++ b/app/tests/Feature/Bot/BotAnswerServiceTest.php @@ -52,6 +52,36 @@ it('стоп-тема со словами между «у меня» и «бал 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(); diff --git a/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php b/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php index 81d2af42..138f84f3 100644 --- a/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php +++ b/app/tests/Feature/Bot/ProcessJivoMessageJobTest.php @@ -52,6 +52,45 @@ it('эскалация: BOT_MESSAGE-прощание + INVITE_AGENT, журна expect(BotDialog::where('direction', 'out')->firstOrFail()->escalated)->toBeTrue(); }); +it('второе сообщение чата уходит в LLM вместе с историей первого', function () { + // Живой урок 12.07.2026: «а сам руками я могу?» без памяти разговора — бессмыслица. + Http::fake([ + 'llm.api.cloud.yandex.net/*' => Http::response([ + 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Конечно.']]]], + ]), + 'bot.jivosite.com/*' => Http::response(['ok' => true]), + ]); + + (new ProcessJivoMessageJob('chat-h', 'client-1', 'что такое проект?'))->handle(); + (new ProcessJivoMessageJob('chat-h', 'client-1', 'а расскажи про проект подробнее'))->handle(); + + $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' => 'Ок.']]]], + ]), + 'bot.jivosite.com/*' => Http::response(['ok' => true]), + ]); + + (new ProcessJivoMessageJob('chat-alien', 'client-9', 'что такое проект?'))->handle(); + (new ProcessJivoMessageJob('chat-mine', 'client-1', 'расскажи про проект'))->handle(); + + $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('что такое проект?'); +}); + it('джоба объявлена с queue=bot и timeout ≤ 12 сек', function () { $job = new ProcessJivoMessageJob('c', 'c', 'q');