diff --git a/app/app/Console/Commands/BotRunQuestionsCommand.php b/app/app/Console/Commands/BotRunQuestionsCommand.php new file mode 100644 index 00000000..b8f0e0cf --- /dev/null +++ b/app/app/Console/Commands/BotRunQuestionsCommand.php @@ -0,0 +1,109 @@ +option('file'); + $out = (string) $this->option('out'); + $asUser = $this->option('as') === 'user'; + + if (! is_file($file)) { + $this->error("Файл с вопросами не найден: {$file}"); + + return self::FAILURE; + } + + /** @var list $questions */ + $questions = json_decode((string) file_get_contents($file), true) ?: []; + if ($questions === []) { + $this->error('Список вопросов пуст.'); + + return self::FAILURE; + } + + $card = null; + if ($asUser) { + $userId = (int) $this->option('user'); + $card = $facts->card($userId); + if ($card === null) { + $this->error("Карточка фактов не собралась для клиента {$userId}."); + + return self::FAILURE; + } + } + + $from = max(1, (int) $this->option('from')); + $to = (int) $this->option('to') ?: count($questions); + + $handle = fopen($out, 'ab'); + if ($handle === false) { + $this->error("Не открыть файл ответов: {$out}"); + + return self::FAILURE; + } + + for ($i = $from; $i <= min($to, count($questions)); $i++) { + $question = (string) $questions[$i - 1]; + $started = microtime(true); + + try { + $answer = $bot->answer($question, [], fromPortal: $asUser, facts: $card); + $row = [ + 'n' => $i, + 'as' => $asUser ? 'user' : 'guest', + 'q' => $question, + 'a' => $answer->text, + 'escalated' => $answer->escalate, + 'chunks' => $answer->matchedChunkIds, + 'ms' => (int) ((microtime(true) - $started) * 1000), + ]; + } catch (\Throwable $e) { + $row = [ + 'n' => $i, + 'as' => $asUser ? 'user' : 'guest', + 'q' => $question, + 'a' => null, + 'error' => $e->getMessage(), + 'ms' => (int) ((microtime(true) - $started) * 1000), + ]; + } + + fwrite($handle, json_encode($row, JSON_UNESCAPED_UNICODE)."\n"); + fflush($handle); + $this->line("[{$i}] ".mb_substr($question, 0, 60)); + } + + fclose($handle); + + return self::SUCCESS; + } +} diff --git a/app/app/Console/Commands/SeedShowcaseCommand.php b/app/app/Console/Commands/SeedShowcaseCommand.php index fc428467..fdbbeed3 100644 --- a/app/app/Console/Commands/SeedShowcaseCommand.php +++ b/app/app/Console/Commands/SeedShowcaseCommand.php @@ -141,7 +141,11 @@ class SeedShowcaseCommand extends Command 'signal_identifier' => 'stomatologiya-ulybka.ru', 'is_active' => true, 'daily_limit_target' => 20, - 'delivered_today' => 12, + // Счётчик проекта обязан сходиться с реальными сделками за сегодня, иначе + // карточка фактов бота противоречит сама себе: «сегодня 12» у проекта и + // «Заявки: сегодня 0» общей строкой (прогон 250×2, 14.07.2026). Сделки демо + // датированы вчерашним днём — значит сегодня по проекту ноль. + 'delivered_today' => 0, 'region_mask' => 255, 'region_mode' => 'include', 'delivery_days_mask' => 127, diff --git a/app/app/Services/Bot/AnswerGuard.php b/app/app/Services/Bot/AnswerGuard.php index 808e1e85..f1d76fbb 100644 --- a/app/app/Services/Bot/AnswerGuard.php +++ b/app/app/Services/Bot/AnswerGuard.php @@ -93,6 +93,30 @@ class AnswerGuard '/баланс[^.!?]{0,30}(не\s+потрат|не\s+тронет|не\s+уйд[её]т|останется\s+нетронут)/iu', // Сроки зачисления по счёту («обычно 1–3 рабочих дня») — в статьях срока НЕТ. '/(зачислен\w*|зачисл\w+|поступ\w+|деньги\s+прид\w+)[^.!?]{0,40}\d[^.!?]{0,15}(рабочих\s+дн|дн[ея])/iu', + // 🔴 Прогон 250×2 (14.07.2026): «увеличьте лимит, и заявок будет больше». Лимит — + // ПОТОЛОК, а не источник (limit-zayavok.md). Если обращений в нише нет, повышение + // лимита не создаст ни одной заявки — а клиент поднимет лимит и будет ждать. + '/(увелич\w+|подним\w+|повыс\w+|поставьте\s+больше)[^.!?]{0,30}лимит[^.!?]{0,40}(больше|прибав|вырас|прид[её]т\s+больше)/iu', + '/лимит[^.!?]{0,30}(больше|выше)[^.!?]{0,30}(заяв\w*\s+)?(будет|станет|прид[её]т)\s+больше/iu', + // 🔴 Прогон 250×2: «Такого не бывает — каждое списание привязано к заявке». На проде + // фантомные списания БЫЛИ (09.07.2026, CSV-reconcile). Отрицать наотрез нельзя — + // человек не станет проверять, а мы потеряем его деньги. + '/такого\s+не\s+бывает/iu', + '/списан\w*[^.!?]{0,40}(всегда|обязательно)\s+привязан/iu', + // 🔴 Владелец 14.07.2026 (живой лог): «номер» и «заявка» — ОДНО И ТО ЖЕ, и мы продаём + // любой объём, хоть одну штуку. Бот выдумал, что «номера поштучно не продаём» (в + // статьях этого нет вовсе) и делил одно на два — клиент слышит отказ там, где отказа нет. + '/не\s+прода[её]м[^.!?]{0,40}(поштучн\w*|по\s+одн\w+|отдельн\w+\s+номер\w*)/iu', + '/(номер\w*|лид\w*)[^.!?]{0,30}(поштучн\w*|по\s+штук\w*)[^.!?]{0,20}не\s+прода/iu', + // NB: «минимального заказа НЕТ» — правда, её резать нельзя. Поэтому ловим только + // УТВЕРЖДЕНИЕ о минимуме: «минимальный заказ — 100 заявок», «пакет от 500 штук». + '/минимальн\w+\s+(объ[её]м|заказ|партия|пакет)\w*[^.!?]{0,20}(—|-|:|от|составля\w+|\d)/iu', + '/(пакет\w*|партия\w*)\s+от\s+\d+/iu', + // 🔴 Владелец 14.07.2026: сколько заявок выйдет на подарочную 1000 ₽ — НЕ ОБЕЩАЕМ и + // числа не называем (besplatnyy-test.md). Сколько придёт, зависит от источника, а на + // тесте по своему сайту их обычно единицы. «Несколько десятков» — враньё в лицо. + '/(1\s?000\s*₽|тысяч\w*|подарк?\w*)[^.!?]{0,40}(несколько\s+десятков|десятк\w+|\d+)[^.!?]{0,20}заяв/iu', + '/(хватит|выйдет|получит\w*)[^.!?]{0,25}(несколько\s+десятков|десятк\w+)[^.!?]{0,15}заяв/iu', ]; /** Висящие союзы в начале — след от вырезанного предложения. */ @@ -156,8 +180,9 @@ class AnswerGuard $kept = []; $cutSomething = false; + $cutPrevious = false; foreach ($sentences as $sentence) { - if ($this->isBanned($sentence) + $cut = $this->isBanned($sentence) || $this->priceTiedToNiche($sentence) || $this->claimsNicheLimits($sentence) || $this->promisesReplacement($sentence) @@ -165,12 +190,27 @@ class AnswerGuard || $this->promisesSupportSla($sentence) || $this->promisesNicheRelevance($sentence) || (! $personalFactsKnown && $this->claimsPersonalState($sentence)) + || $this->callsBalanceAGift($sentence, $facts) + || $this->claimsWrongProjectState($sentence, $facts) || $this->hasFactMismatch($sentence, $facts) - || $this->hasInventedNumber($sentence, $allowed)) { + || $this->hasInventedNumber($sentence, $allowed); + + // 🔴 Прогон 250×2 (14.07.2026): вырезали предложение — следующее осталось + // обрубком, который ссылается на выброшенное: «Они лежат на счёте», «Нажмите + // её, подтвердите». Клиент читает бессмыслицу. Такие предложения-хвосты + // (начинаются с местоимения без хозяина) снимаем вместе с вырезанным. + if (! $cut && $cutPrevious && $this->startsWithOrphanPronoun($sentence)) { + $cut = true; + } + + if ($cut) { $cutSomething = true; + $cutPrevious = true; continue; } + + $cutPrevious = false; $kept[] = $sentence; } @@ -217,13 +257,20 @@ class AnswerGuard return false; } - $subject = preg_match('/(ступен\w*|баланс\w*|тариф\w*|объ[её]м\w*|заяв\w*|списан\w*)/iu', $sentence) === 1; + // 🔴 Прогон 250×2 (14.07.2026): гостю сказали «У вас уже есть 1000 ₽ подарка — они + // лежат на СЧЁТЕ». Слова «счёт», «подарок», «деньги», «кабинет», «проект» в списке + // не было — выдумка проскочила. Список личных понятий пополнен. + $subject = preg_match( + '/(ступен\w*|баланс\w*|тариф\w*|объ[её]м\w*|заяв\w*|списан\w*' + .'|сч[её]т\w*|подар\w*|деньг\w*|средств\w*|проект\w*|кабинет\w*|лимит\w*)/iu', + $sentence + ) === 1; if (! $subject) { return false; } // «сейчас/уже/находитесь/текущая» — маркеры утверждения о нынешнем состоянии. - $rightNow = preg_match('/(сейчас|уже|находит\w*|текущ\w*|на данный момент|в этом месяце)/iu', $sentence) === 1; + $rightNow = preg_match('/(сейчас|уже|находит\w*|текущ\w*|на данный момент|в этом месяце|лежат|лежит)/iu', $sentence) === 1; // «у вас на балансе 11 025 ₽» — притяжательное + число, даже без «сейчас». $ownedNumber = preg_match('/(у\s+вас|ваш\w*)[^.!?]{0,40}\d/iu', $sentence) === 1; @@ -231,6 +278,115 @@ class AnswerGuard return $rightNow || $ownedNumber; } + /** + * 🔴 Прогон 250×2 (14.07.2026): ВОШЕДШЕМУ клиенту бот назвал его баланс «подарком + * портала» («У вас на балансе 11 025 ₽ — это подарок портала»). Подарок был 1000 ₽ при + * регистрации и давно потрачен; человек решит, что на счету не его деньги. + * + * Правило: вошедшему нельзя называть ЕГО баланс подарком/бесплатным, если баланс + * в карточке — не ровно сумма подарка. + * + * @param array|null $facts + */ + private function callsBalanceAGift(string $sentence, ?array $facts): bool + { + if ($facts === null || ! array_key_exists('balance', $facts)) { + return false; + } + + // Подарок ещё лежит нетронутым — тогда всё честно, резать нечего. + if ($facts['balance'] === '1000') { + return false; + } + + $aboutHisMoney = preg_match('/(у\s+вас|ваш\w*)[^.!?]{0,30}(баланс\w*|сч[её]т\w*)/iu', $sentence) === 1 + || preg_match('/(баланс\w*|сч[её]т\w*)[^.!?]{0,20}(у\s+вас|ваш\w*)/iu', $sentence) === 1; + $callsGift = preg_match('/(подар\w*|бесплатн\w*)/iu', $sentence) === 1; + + if ($aboutHisMoney && $callsGift) { + return true; + } + + // Сценарий новичка, наложенный на давнего клиента: «первые заявки идут за счёт + // подарка, пополнять ничего не нужно». Притяжательного тут нет, но утверждение — + // про ЕГО деньги, и оно ложное: подарок он потратил давно (прогон 250×2, 14.07.2026). + $rookieScenario = preg_match( + '/(за\s+сч[её]т\s+подарк\w*|подарочн\w+\s+(тысяч|1000)|подарк\w*[^.!?]{0,25}перв\w+\s+заяв)/iu', + $sentence + ) === 1 + || preg_match('/(пополнять|вкладывать)[^.!?]{0,20}(ничего\s+)?не\s+нужно/iu', $sentence) === 1; + + return $rookieScenario; + } + + /** + * 🔴 Прогон 250×2 (14.07.2026): бот врал ВОШЕДШЕМУ про состояние его проектов — + * «У вас два работают» (работает один), «Отбеливание — Казань работает» (остановлен + * из-за нехватки денег). Клиент на этом теряет деньги: не узнает, что проект встал. + * + * Сверяем два утверждения: + * 1. «N работают / N активных» — с реальным числом работающих проектов; + * 2. «„Название“ работает» — с реальным состоянием ИМЕННО этого проекта. + * + * @param array|null $facts + */ + private function claimsWrongProjectState(string $sentence, ?array $facts): bool + { + if ($facts === null || ! array_key_exists('projects_states', $facts)) { + return false; + } + + /** @var array $states название → состояние */ + $states = json_decode($facts['projects_states'], true) ?: []; + $working = count(array_filter($states, fn (string $s) => $s === 'работает')); + + // 1. Сколько проектов «работают/активны». + if (preg_match('/(\d+|один|одна|два|две|три|четыре|пять)\s+(?:\S+\s+){0,2}(работа\w+|активн\w+)/iu', $sentence, $m) === 1) { + $claimed = (int) $this->normalizeNumberToken($m[1]); + if ($claimed !== $working) { + return true; + } + } + + // 2. Про конкретный проект по имени в кавычках. + if (preg_match_all('/«([^»]+)»/u', $sentence, $names) === false) { + return false; + } + + foreach ($names[1] ?? [] as $name) { + $state = $states[$name] ?? null; + if ($state === null) { + continue; + } + + $saysWorking = preg_match('/(работает|активен|активн\w*|идут\s+заявки|заявки\s+идут|запущен)/iu', $sentence) === 1; + $saysStopped = preg_match('/(на\s+паузе|остановлен|приостановлен|не\s+работает|стоит)/iu', $sentence) === 1; + + if ($state === 'работает' && $saysStopped && ! $saysWorking) { + return true; + } + if ($state !== 'работает' && $saysWorking && ! $saysStopped) { + return true; + } + } + + return false; + } + + /** + * Предложение-хвост: начинается с местоимения, у которого «хозяин» остался в + * вырезанном предложении — «Они лежат на счёте», «Нажмите её, подтвердите». + * Само по себе такое предложение клиенту ничего не говорит. + */ + private function startsWithOrphanPronoun(string $sentence): bool + { + return preg_match( + '/^\s*(они|он|она|оно|их|его|е[её]|этот|эта|это|эти|там|туда|тогда)\b/iu', + $sentence + ) === 1 + || preg_match('/^\s*\S+\s+(её|его|их)\b/iu', $sentence) === 1; + } + public function polishStart(string $text): string { $text = (string) preg_replace(self::DANGLING_START, '', trim($text)); @@ -294,7 +450,15 @@ class AnswerGuard $object = preg_match('/(заявк\w*|номер\w*|лид\w*|сделк\w*|деньг\w*|средств\w*)/iu', $sentence) === 1; $denial = preg_match('/(не\s+замен|не\s+меня|не\s+верн|не\s+возвращ|замены\s+нет|без\s+замен|не\s+компенсир)/iu', $sentence) === 1; - return $promise && $object && ! $denial; + // 🔑 Возврат НЕИЗРАСХОДОВАННОГО ОСТАТКА баланса — это ПРАВДА (Политика возврата, + // vozvrat-deneg.md): по заявлению на почту, до 3 рабочих дней, за вычетом комиссий. + // Запрещено обещать возврат денег ЗА ЗАЯВКУ — услуга оказана. Прогон 250×2 + // (14.07.2026): бот сам придумал процедуру возврата остатка (угадал верно, но мог и + // соврать) — теперь правда лежит в статье, и резать её нельзя. + $aboutBalanceLeftover = preg_match('/(остат\w*|баланс\w*|неизрасходован\w*)/iu', $sentence) === 1 + && preg_match('/(за\s+заявк|за\s+лид|за\s+номер|за\s+сделк|стоимость\s+заявк)/iu', $sentence) !== 1; + + return $promise && $object && ! $denial && ! $aboutBalanceLeftover; } /** @@ -470,6 +634,28 @@ class AnswerGuard if (preg_match('/Проекты\s*\((\d+)\)/u', $context, $m) === 1) { $facts['projects_count'] = $m[1]; } + + // Состояние КАЖДОГО проекта: «Название» — работает / на паузе / остановлен. + // Прогон 250×2 (14.07.2026): бот выдавал остановленный проект за работающий — + // одного числа проектов для проверки мало, нужно состояние каждого. + if (preg_match('/Проекты\s*\(\d+\):(.+?)(?:\n|$)/u', $context, $m) === 1) { + $states = []; + foreach (explode(';', $m[1]) as $part) { + if (preg_match('/«([^»]+)»\s*—\s*(.+)/u', trim($part), $p) !== 1) { + continue; + } + $name = $p[1]; + $tail = mb_strtolower($p[2]); + $states[$name] = match (true) { + str_contains($tail, 'на паузе') => 'на паузе', + str_contains($tail, 'остановлен') => 'остановлен', + default => 'работает', + }; + } + if ($states !== []) { + $facts['projects_states'] = (string) json_encode($states, JSON_UNESCAPED_UNICODE); + } + } if (preg_match( '/Заявки:\s*сегодня\s+(\d+),\s*вчера\s+(\d+),\s*за\s*7\s*дней\s+(\d+),\s*за\s*месяц\s+(\d+)/u', $context, diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index 0c3fe96a..52e7058e 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -260,8 +260,15 @@ class BotAnswerService обычный текст. — ЦЕНА ЗАВИСИТ ТОЛЬКО ОТ СТУПЕНИ ОБЪЁМА. Никогда не связывай её с нишей, регионом, городом, источником или «категорией» — такой зависимости нет. И не считай за - клиента, сколько заявок выйдет на его сумму («на тысячу — от 2 до 20») — назови - цену ступени и предложи посмотреть сетку в Биллинге. + клиента, сколько заявок выйдет на его сумму («на тысячу — от 2 до 20») и сколько + заявок выйдет на подарочную тысячу — этого мы не обещаем. + — СПРОСИЛИ ЦЕНУ — НАЗОВИ ЦИФРУ СРАЗУ, первой фразой, из статьи «Тарифы». «Сколько + стоит заявка?», «сколько стоит один номер?», «от и до?» — отвечай вилкой + («заявка стоит от … до …»), а уже потом объясняй ступени. Отправлять смотреть + цену в «Биллинг» ВМЕСТО ответа — нельзя: человек уходит, так и не узнав цену. + — «НОМЕР», «ЗАЯВКА», «ЛИД», «КОНТАКТ» — ЭТО ОДНО И ТО ЖЕ. Не дели их и не говори, + что «номера поштучно мы не продаём»: это неправда. Мы продаём любой объём, + который просит клиент, хоть одну штуку. Минимального заказа нет. — НЕ ОБЕЩАЙ ЗАМЕНУ ЗАЯВКИ И ВОЗВРАТ ДЕНЕГ. Недозвон («не берёт трубку») — обычная часть работы, а не брак: замены за это нет. Но и грубить нельзя — не говори «замена не полагается, деньги не вернём». Скажи мягко: это нормально, посоветуй @@ -325,6 +332,11 @@ class BotAnswerService array_filter($history, fn ($m) => ($m['role'] ?? '') === 'assistant') )); + // 🔴 Живая проверка 14.07.2026: клиент спросил «можно купить всего 5 номеров?» — бот + // ответил, а сторож вырезал фразу с «5 номеров» как выдуманное число. Число назвал + // САМ КЛИЕНТ: повторить его в ответе — не выдумка. Отдаём сторожу и сам вопрос. + $said .= ' '.$question; + // Гостю с лендинга личные цифры приписывать НЕЛЬЗЯ: он не вошёл, заявок и ступени // у него нет. Карточка фактов есть только у вошедшего — по ней и решаем. $personalFactsKnown = $facts !== null && trim($facts) !== ''; @@ -378,7 +390,13 @@ class BotAnswerService // источников в заголовке стоит слово «цена», а в синонимах «50 рублей» — поиск // ставил её выше «Тарифов», и на денежные вопросы бот слал человека в чужую тему. // Порядок поиска — тай-брейк. - $linkChunks = collect($linkChunks) + // + // 🔴 Прогон 250×2 (14.07.2026): ссылка всё ещё уезжала в чужую тему («что такое лид?» + // → разбор про тарифы, «как удалить проект?» → разбор про смену источника). Порог + // «половина вопроса» помог, но не вылечил: третья-четвёртая статья выдачи иногда + // случайно набирает слов больше, чем первая. Поэтому ссылку берём ТОЛЬКО из двух + // самых релевантных статей поиска: дальше начинается угадайка. + $linkChunks = collect(array_slice($linkChunks, 0, 2)) ->filter(fn ($c) => $this->search->confidentTopicMatch($c, $question)) ->sortByDesc(fn ($c) => $this->search->topicScore($c, $question)) ->values() diff --git a/app/resources/help/besplatnyy-test.md b/app/resources/help/besplatnyy-test.md index b8725121..7e4a70ab 100644 --- a/app/resources/help/besplatnyy-test.md +++ b/app/resources/help/besplatnyy-test.md @@ -8,8 +8,15 @@ topics: бесплатно, бесплатный тест, тестовый пе первые заявки идут за счёт этих денег. Своих вкладывать не нужно: пополняете баланс только тогда, когда решите продолжить. -Важно: 1000 ₽ — это **подарок, который уже лежит на вашем балансе**, а не сумма, которую -надо внести. +Важно: 1000 ₽ — это **подарок, который уже лежит на балансе после регистрации**, а не сумма, +которую надо внести. У человека, который ещё не зарегистрировался, никакого баланса нет — +подарок появится вместе с кабинетом. + +🔴 **Сколько заявок выйдет на подарок — НЕ ОБЕЩАЕМ и числа не называем.** Ни «несколько +десятков», ни «примерно столько-то»: сколько придёт, зависит от того, сколько обращений +вообще есть по выбранному источнику, а на тесте по своему сайту их обычно немного. +Правильный ответ: «дарим 1000 ₽, заявка стоит {{вилка}} — сколько получится, увидите сами, +списания видны по каждой заявке». **Как честно проверить качество данных.** Самый показательный тест — **на своём собственном номере или сайте**: создайте проект на свой номер телефона или сайт diff --git a/app/resources/help/limit-zayavok.md b/app/resources/help/limit-zayavok.md index 3d548187..8c97eee5 100644 --- a/app/resources/help/limit-zayavok.md +++ b/app/resources/help/limit-zayavok.md @@ -9,7 +9,14 @@ topics: лимит, лимит в день, сколько заявок в де в карточке проекта или сразу по нескольким проектам массовым действием. Лимит — это **потолок, а не гарантия**. Больше него за день не придёт никогда. -А вот меньше — бывает, и это нормально. Частые причины: +А вот меньше — бывает, и это нормально. + +**Повышение лимита НЕ создаёт заявок.** Если в вашей нише и регионе за день было мало +обращений, лимит хоть 10 000 — придёт столько, сколько было. Поднимать лимит имеет смысл +только тогда, когда заявок УПИРАЕТСЯ в потолок: приходит ровно столько, сколько вы +поставили. Если приходит меньше лимита — дело не в лимите, и поднимать его бесполезно. + +Частые причины, почему пришло меньше лимита: - **В этот день в вашей нише и регионе просто не набралось столько обращений.** Заявки идут потоком в течение дня, а не пачкой: то гуще, то реже. diff --git a/app/resources/help/sozdat-proekt.md b/app/resources/help/sozdat-proekt.md index 6af5c6e4..a1898eb2 100644 --- a/app/resources/help/sozdat-proekt.md +++ b/app/resources/help/sozdat-proekt.md @@ -34,6 +34,9 @@ topics: создать проект, новый проект, форма про 5. **Регионы** — конкретные субъекты РФ (области, края, республики) или галочка **«Вся РФ (все регионы)»** — да, всю страну поставить можно. Отдельный **город** выбрать нельзя: география задаётся субъектами. + **Москва и Санкт-Петербург — это самостоятельные субъекты**, они есть в списке отдельно. + Не подменяйте их областями: Московская и Ленинградская области — ДРУГИЕ субъекты, и заявки + придут не из города, а из области. 6. **Дни недели приёма** — заявки идут **только в отмеченные дни**. Есть быстрые кнопки «Будни» и «Все дни». Хотите заявки в субботу и воскресенье — отметьте выходные (или нажмите «Все дни»); оставите только будни — в выходные потока не будет. diff --git a/app/resources/help/tarify.md b/app/resources/help/tarify.md index 3bca0396..b3c8a75f 100644 --- a/app/resources/help/tarify.md +++ b/app/resources/help/tarify.md @@ -1,12 +1,18 @@ --- title: Тарифы — сколько стоит заявка tour: tariffs -topics: тариф, тарифы, где посмотреть ступень, какая у меня ступень, скидка за объём, скидка за объем, дешевеет ли, какие тарифы, цена, сколько стоит заявка, стоимость одной заявки, цена за лид, почему так дорого, дорого, высокая цена, ступени, 7 ступеней, скидка за объём, чем больше тем дешевле, абонплата, абонентская плата, есть ли абонентская плата, ежемесячная плата, подписка, комиссия, вы здесь, цена в следующем месяце +topics: тариф, тарифы, где посмотреть ступень, какая у меня ступень, скидка за объём, скидка за объем, дешевеет ли, какие тарифы, цена, сколько стоит заявка, стоимость одной заявки, цена за лид, сколько стоит один номер, цена одного номера, цена за номер, почём номер, сколько стоит номер, сколько стоит контакт, цена контакта, от и до, минимальный заказ, можно ли купить один номер, купить несколько номеров, продаёте ли поштучно, почему так дорого, дорого, высокая цена, ступени, 7 ступеней, скидка за объём, чем больше тем дешевле, абонплата, абонентская плата, есть ли абонентская плата, ежемесячная плата, подписка, комиссия, вы здесь, цена в следующем месяце --- **Коротко: заявка стоит {{вилка}}.** Так и отвечаем на вопрос «сколько стоит заявка» или «от и до» — цифрой сразу, а не «смотрите в Биллинге». +🔴 **«Номер», «заявка», «лид», «контакт» — это ОДНО И ТО ЖЕ.** Один номер = одна заявка = +одна цена. Никакого деления на «номера» и «заявки» нет, и **никаких пакетов тоже нет**: +мы продаём **любой объём, который попросит клиент, — хоть одну штуку**. Минимального заказа +нет. Отвечать «мы не продаём номера поштучно» — **НЕПРАВДА**: на вопрос «сколько стоит один +номер?» правильный ответ — назвать цену заявки ({{вилка}}). + Абонентской платы нет. Вы платите **только за фактически полученные заявки** — деньги списываются в момент, когда заявка приходит к вам в «Сделки». @@ -18,6 +24,11 @@ topics: тариф, тарифы, где посмотреть ступень, к Сейчас сетка такая: {{тарифы}}. +**Ступень меняется только когда набран ОБЪЁМ ступени, а не «через сколько-то заявок».** +Сколько заявок входит в каждую ступень — видно в сетке выше. Пока объём первой ступени +не набран, все заявки идут по её цене: и первая, и сотая, и любая до конца ступени. +Говорить «сотая заявка уже дешевле» — неправда. + Цена одинаковая для всех — личных сеток и индивидуальных условий нет. Если клиент говорит, что видел другую цифру, — он видел свою ступень внутри той же общей сетки. diff --git a/app/resources/help/vozvrat-deneg.md b/app/resources/help/vozvrat-deneg.md new file mode 100644 index 00000000..56688553 --- /dev/null +++ b/app/resources/help/vozvrat-deneg.md @@ -0,0 +1,26 @@ +--- +title: Возврат денег с баланса +tour: top-up-balance +topics: возврат, вернуть деньги, вывести деньги, вывод остатка, остаток баланса, забрать деньги, можно ли вернуть деньги, возврат остатка, неизрасходованный остаток, передумал, закрыть аккаунт и забрать деньги, деньги обратно, возврат за заявку, вернуть деньги за заявку, возврат средств +--- + +**Неизрасходованный остаток баланса вернуть можно.** Это записано в «Политике возврата», +она открыта в кабинете и на странице входа — раздел **«Условия возврата»** (`/legal/refund`). + +**Как это работает:** + +- Возврат делается **по заявлению** — письмом на `support@liderra.ru`. Кнопки «вывести + деньги» в кабинете нет: это живой разбор, а не автоматическая операция. +- Деньги возвращаются **на те же реквизиты, с которых была оплата**, тем же способом. +- Срок — **до 3 (трёх) рабочих дней** с момента получения заявления, **за вычетом комиссий + платёжных систем** (комиссию удерживает банк или платёжный сервис, не мы). + +**Что вернуть нельзя.** Стоимость **уже переданных заявок** возврату не подлежит: услуга +считается оказанной в момент, когда заявка пришла в «Сделки». Недозвон, неответ или то, что +человек не купил, — это не брак заявки и не основание для возврата денег за неё. +Единственное исключение — нарушение условий оферты с нашей стороны, и такие случаи +разбирает специалист индивидуально. + +**Коротко для чата:** остаток — да, по заявлению на почту, до 3 рабочих дней, за вычетом +комиссий. Деньги за полученные заявки — нет. Спорную ситуацию всегда можно разобрать +со специалистом: напишите на `support@liderra.ru`. diff --git a/app/tests/Feature/Bot/BotRunQuestionsCommandTest.php b/app/tests/Feature/Bot/BotRunQuestionsCommandTest.php new file mode 100644 index 00000000..34ebc010 --- /dev/null +++ b/app/tests/Feature/Bot/BotRunQuestionsCommandTest.php @@ -0,0 +1,57 @@ +set('services.support.email', 'support@liderra.ru'); + 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/b.md', 'title' => 'Как пополнить баланс', 'tour' => null, + 'topics' => 'пополнить, баланс', 'chunk_index' => 0, + 'content' => 'Пополнить баланс можно картой или по счёту.', + ]); +}); + +it('прогоняет вопросы и пишет ответы построчно', function () { + Http::fake(['llm.api.cloud.yandex.net/*' => Http::response([ + 'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Пополнить баланс можно в разделе «Биллинг».']]]], + ])]); + + $dir = sys_get_temp_dir().'/bot-run-'.uniqid(); + mkdir($dir); + $file = $dir.'/q.json'; + $out = $dir.'/out.jsonl'; + file_put_contents($file, json_encode(['как пополнить баланс?', 'а картой можно?'], JSON_UNESCAPED_UNICODE)); + + $this->artisan('bot:run-questions', ['--file' => $file, '--out' => $out, '--as' => 'guest']) + ->assertExitCode(0); + + $lines = array_filter(explode("\n", (string) file_get_contents($out))); + expect($lines)->toHaveCount(2); + + $first = json_decode((string) $lines[0], true); + expect($first['n'])->toBe(1) + ->and($first['as'])->toBe('guest') + ->and($first['q'])->toBe('как пополнить баланс?') + ->and($first['a'])->toContain('Биллинг'); +}); + +it('без файла вопросов честно падает, а не делает вид, что прогнал', function () { + $this->artisan('bot:run-questions', ['--file' => 'нет-такого.json', '--out' => 'x.jsonl']) + ->assertExitCode(1); +}); diff --git a/app/tests/Feature/Bot/MassRunFixesTest.php b/app/tests/Feature/Bot/MassRunFixesTest.php index 5a77b202..6308e577 100644 --- a/app/tests/Feature/Bot/MassRunFixesTest.php +++ b/app/tests/Feature/Bot/MassRunFixesTest.php @@ -94,8 +94,11 @@ it('знает про бесплатный тест и подарок 1000 ₽ ->and($hits[0]->title)->toContain('Бесплатный тест'); $text = collect($hits)->pluck('content')->implode(' '); + // Формулировка «лежит на ВАШЕМ балансе» убрана (прогон 250×2, 14.07.2026): с ней бот + // говорил ГОСТЮ «у вас уже есть 1000 ₽ на счёте», хотя счёта у него нет. Смысл тот же — + // подарок, а не сумма пополнения, — но без «вашего» счёта у незарегистрированного. expect($text)->toContain('подарок') - ->and($text)->toContain('уже лежит на вашем балансе'); + ->and($text)->toContain('уже лежит на балансе после регистрации'); }); it('знает, как работать с полученным номером: сначала СМС, через час звонок', function () { diff --git a/app/tests/Feature/Bot/Run250FixesTest.php b/app/tests/Feature/Bot/Run250FixesTest.php new file mode 100644 index 00000000..0be4e738 --- /dev/null +++ b/app/tests/Feature/Bot/Run250FixesTest.php @@ -0,0 +1,170 @@ +clean($text, 'При регистрации мы дарим 1000 ₽ на баланс.', personalFactsKnown: false); + + expect($clean)->not->toContain('У вас уже есть') + ->and($clean)->not->toContain('лежат на счёте') + // Общее объяснение про подарок остаётся: человек не должен остаться без ответа. + ->and($clean)->toContain('дарим 1000 ₽'); +}); + +it('вошедшему не называют его баланс подарком портала', function () use ($card) { + $guard = app(AnswerGuard::class); + + $text = 'У вас на балансе 11 025 ₽ — это подарок портала, первые заявки пойдут за его счёт.'; + $clean = $guard->clean($text, $card, personalFactsKnown: true); + + expect($clean)->not->toContain('подарок портала'); +}); + +it('давнему клиенту не подсовывают сценарий новичка «живите на подарок»', function () use ($card) { + $guard = app(AnswerGuard::class); + + // Подарок в 1000 ₽ этот клиент потратил давно: баланс 11 025 ₽, за месяц списано 1 375 ₽. + $text = 'Баланс — у вас уже есть. Первые заявки идут за счёт подарка, пополнять ничего не нужно. Проект создаётся в разделе «Проекты».'; + $clean = $guard->clean($text, $card, personalFactsKnown: true); + + expect($clean)->not->toContain('за счёт подарка') + ->and($clean)->not->toContain('пополнять ничего не нужно') + ->and($clean)->toContain('разделе «Проекты»'); +}); + +it('новичку, у которого подарок нетронут, про подарок сказать можно', function () { + $guard = app(AnswerGuard::class); + + $freshCard = "ДАННЫЕ ЭТОГО КЛИЕНТА (посчитаны только что):\nБаланс: 1000 ₽ ≈ 18 заявок.\n"; + $text = 'Первые заявки идут за счёт подарка, пополнять ничего не нужно.'; + + expect($guard->clean($text, $freshCard, personalFactsKnown: true))->toContain('за счёт подарка'); +}); + +it('вошедшему не врут про состояние его проектов', function () use ($card) { + $guard = app(AnswerGuard::class); + + // Правда: работает ОДИН проект, один на паузе, один остановлен из-за денег. + $text = 'У вас сейчас три проекта: два работают («Стоматология — Москва» и «Отбеливание — Казань»), один на паузе.'; + $clean = $guard->clean($text, $card, personalFactsKnown: true); + + expect($clean)->not->toContain('два работают'); +}); + +it('вошедшему не выдают остановленный проект за работающий', function () use ($card) { + $guard = app(AnswerGuard::class); + + $text = 'Проект «Отбеливание — Казань» работает, заявки по нему идут.'; + $clean = $guard->clean($text, $card, personalFactsKnown: true); + + expect($clean)->not->toContain('«Отбеливание — Казань» работает'); +}); + +it('бот не обещает, что от повышения лимита заявок станет больше', function () { + $guard = app(AnswerGuard::class); + + $text = 'Лимит — это потолок, а не гарантия. Если заявок стабильно меньше, чем нужно, — увеличьте лимит, и их будет больше.'; + $clean = $guard->clean($text, 'Лимит — это потолок, а не гарантия.', personalFactsKnown: false); + + expect($clean)->toContain('потолок') + ->and($clean)->not->toContain('их будет больше'); +}); + +it('бот не заявляет «такого не бывает» про списание без заявки', function () { + $guard = app(AnswerGuard::class); + + $text = 'Такого не бывает — каждое списание привязано к конкретной заявке. Проверить каждое списание можно на вкладке «Списания».'; + $clean = $guard->clean($text, 'Проверить каждое списание можно на вкладке «Списания».', personalFactsKnown: false); + + expect($clean)->not->toContain('Такого не бывает') + ->and($clean)->toContain('вкладке «Списания»'); +}); + +it('номер и заявка — одно и то же: «поштучно не продаём» бот больше не говорит', function () { + // Живой лог 14.07.2026, замечание владельца: «номера это и есть заявки, а он их делит». + // Мы продаём любой объём, хоть одну штуку — отказа в поштучной продаже нет и не было. + $guard = app(AnswerGuard::class); + + $text = 'Мы не продаём номера поштучно — платите вы за заявки, которые получаете. Заявка стоит от 55 ₽ до 25 ₽.'; + $clean = $guard->clean($text, 'Заявка стоит от 55 ₽ до 25 ₽. Номер, заявка и лид — это одно и то же.', personalFactsKnown: false); + + expect($clean)->not->toContain('поштучно') + ->and($clean)->toContain('от 55 ₽'); +}); + +it('число, названное самим клиентом, сторож не считает выдумкой', function () { + $guard = app(AnswerGuard::class); + + // Клиент спросил «можно купить всего 5 номеров?» — повторить его же «5 номеров» + // в ответе не выдумка. Сторожу отдаётся и текст вопроса (BotAnswerService). + $context = "Минимального заказа нет.\n\nможно купить всего 5 номеров?"; + $text = 'Да, конечно — можно взять и 5 номеров, минимального заказа нет.'; + + expect($guard->clean($text, $context, personalFactsKnown: false))->toContain('5 номеров'); +}); + +it('сколько заявок выйдет на подарочную тысячу — бот не обещает', function () { + $guard = app(AnswerGuard::class); + + $text = 'При регистрации дарим 1000 ₽ — этого хватит на несколько десятков заявок. Заявка стоит от 55 ₽.'; + $clean = $guard->clean($text, 'При регистрации дарим 1000 ₽. Заявка стоит от 55 ₽.', personalFactsKnown: false); + + expect($clean)->not->toContain('несколько десятков') + ->and($clean)->toContain('от 55 ₽'); +}); + +it('правду про возврат ОСТАТКА баланса сторож не режет', function () { + $guard = app(AnswerGuard::class); + + $article = 'Неизрасходованный остаток баланса возвращается по заявлению на support@liderra.ru, ' + .'до 3 рабочих дней, за вычетом комиссий. Стоимость уже переданных заявок возврату не подлежит.'; + $text = 'Неизрасходованный остаток баланса можно вернуть по заявлению на support@liderra.ru — деньги придут на те же реквизиты.'; + + $clean = $guard->clean($text, $article, personalFactsKnown: false); + + expect($clean)->toContain('остаток баланса можно вернуть'); +}); + +it('обещание вернуть деньги ЗА ЗАЯВКУ сторож по-прежнему режет', function () { + $guard = app(AnswerGuard::class); + + $text = 'Мы вернём вам деньги за эту заявку, раз человек не взял трубку.'; + $clean = $guard->clean($text, 'Недозвон — не брак.', personalFactsKnown: false); + + expect($clean)->not->toContain('вернём вам деньги'); +}); + +it('после вырезанного предложения не остаётся обрубок «Они лежат…»', function () { + $guard = app(AnswerGuard::class); + + // Первое предложение — выдумка про гостя, его режем. Второе без него бессмысленно. + $text = 'У вас уже есть 1000 ₽ подарка. Они лежат на счёте, и первые заявки идут за их счёт. Пополнять баланс можно картой или по счёту.'; + $clean = $guard->clean($text, 'Пополнять баланс можно картой или по счёту.', personalFactsKnown: false); + + expect($clean)->not->toContain('Они лежат') + ->and($clean)->toContain('картой или по счёту'); +});