clean( 'Человек дал согласие оператору. Нет, вас не оштрафуют. Пишите на support@liderra.ru.', context: 'Человек дал согласие оператору.' ); expect($answer)->not->toContain('не оштрафуют') ->and($answer)->toContain('Человек дал согласие оператору') ->and($answer)->toContain('support@liderra.ru'); }); it('вырезает «мы гарантируем» и «не нарушаем закон»', function () { $guard = app(AnswerGuard::class); expect($guard->clean('Мы гарантируем, что человек живой. Контакт свежий.', context: 'Контакт свежий.')) ->not->toContain('гарантируем') ->and($guard->clean('Нет, мы не нарушаем закон. Данные передают операторы.', context: 'Данные передают операторы.')) ->not->toContain('не нарушаем'); }); it('вырезает цену, которой нет в наших статьях', function () { // Живая выдумка модели: «при 100+ заявках цена 450 ₽». Такого порога у нас нет. $guard = app(AnswerGuard::class); $answer = $guard->clean( 'Цена падает с объёмом. При 100 заявках она станет 450 ₽. Точную цену видно на плитке баланса.', context: 'Цена зависит от объёма: ступени от 500 ₽. Точную цену видно на плитке баланса.' ); expect($answer)->not->toContain('450') ->and($answer)->toContain('Точную цену видно на плитке баланса'); }); it('цену ИЗ статьи не трогает', function () { $guard = app(AnswerGuard::class); $answer = $guard->clean( 'Подбор конкурентов стоит 300 ₽.', context: 'Цена подбора — 300 ₽ за запуск.' ); expect($answer)->toContain('300'); }); it('если после чистки ничего не осталось — отдаёт пусто, бот позовёт специалиста', function () { $guard = app(AnswerGuard::class); expect($guard->clean('Нет, вас не оштрафуют.', context: 'Что-то про заявки.'))->toBe(''); }); it('режет количество заявок, которого нет в материалах', function () { $guard = new App\Services\Bot\AnswerGuard; $context = 'ДАННЫЕ ЭТОГО КЛИЕНТА: Заявки: сегодня 12, вчера 18.'; $text = 'Сегодня пришло 12 заявок. Завтра придёт 30 заявок.'; $clean = $guard->clean($text, $context); expect($clean)->toContain('12 заявок') ->and($clean)->not->toContain('30 заявок'); }); it('пропускает число дней, взятое из карточки фактов', function () { $guard = new App\Services\Bot\AnswerGuard; $context = 'Баланс: 3 400 ₽ ≈ 40 заявок, при текущем заказе хватит примерно на 5 дней.'; expect($guard->clean('Денег хватит примерно на 5 дней.', $context)) ->toContain('на 5 дней'); }); /** * 🔴 КРИТИЧНОЕ замечание ревьюера (13.07.2026): сторож на регулярках ловил только числа, * которых НЕТ в материалах вовсе. Мимо проходили: числа словами («два проекта»), чужие * единицы («сделок», «клиент», «сутки»), разрыв «число ... единица» («20 в день»), и * САМОЕ ОПАСНОЕ — число, которое в карточке ЕСТЬ, но относится к ДРУГОМУ факту (модель * перепутала местами). Карточку (ClientFacts) генерирует КОД — формат предсказуем, * поэтому сторож разбирает её на пары «факт → правильное число» и ловит перепутанное. * * Ловушечная карточка: специально подобранные числа, чтобы никакое совпадение не было * случайным — «40» существует (баланс ≈40 заявок), но «получено в этом месяце» — 12. */ function trapCard(): string { return "ДАННЫЕ ЭТОГО КЛИЕНТА (посчитаны только что):\n" .'Баланс: 3 400 ₽ ≈ 40 заявок, при текущем заказе хватит примерно на 7 дней.'."\n" .'Ступень: 1-я, 50 ₽ за заявку, получено в этом месяце 12 заявок, до следующей ступени — 88 заявок.'."\n" .'Проекты (4): «Стоматология МСК» — работает, лимит 15 в день, сегодня 9; ' .'«Клиника СПб» — на паузе с 13.07 (паузу поставил клиент); ' .'«Без денег» — остановлен из-за нехватки денег; «Выключенный» — выключен.'."\n" .'Заявки: сегодня 2, вчера 1, за 7 дней 3, за месяц 9.'."\n" .'Списано за месяц: 150 ₽. Последнее пополнение: 5 000 ₽ 11.07.2026.'; } it('🔴 ГЛАВНОЕ: перепутанное число («получено» ≠ «40» из баланса) — вырезано', function () { $guard = new App\Services\Bot\AnswerGuard; // 40 РЕАЛЬНО есть в карточке (баланс ≈ 40 заявок), но не как «получено в этом месяце» // (там 12). Старое правило (число не из материалов) это пропустит — новое обязано поймать. $out = $guard->clean('В этом месяце получено 40 заявок.', trapCard()); expect($out)->not->toContain('40')->and($out)->toBe(''); }); it('«сегодня пришло 12 заявок» при «Заявки: сегодня 2» — вырезано', function () { $guard = new App\Services\Bot\AnswerGuard; // 12 тоже РЕАЛЬНО есть в карточке (получено в этом месяце), но это не «сегодня» (там 2). $out = $guard->clean('Сегодня пришло 12 заявок.', trapCard()); expect($out)->toBe(''); }); it('«у вас два проекта» при «Проекты (4)» — число словами тоже ловим', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean('У вас два проекта.', trapCard()); expect($out)->toBe(''); }); it('чужая единица «сделок», которой в карточке нет вовсе — вырезано', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean('У вас сегодня 12 сделок.', trapCard()); expect($out)->toBe(''); }); it('разрыв «число ... единица» через слово («20 в день») — тоже ловим', function () { $guard = new App\Services\Bot\AnswerGuard; // 20 нигде в карточке нет (лимит проекта — 15). $out = $guard->clean('Обычно у вас выходит 20 в день.', trapCard()); expect($out)->toBe(''); }); it('новая единица «суток» — число, которого нет в карточке, вырезано', function () { $guard = new App\Services\Bot\AnswerGuard; // 5 нигде в карточке нет как отдельное число (только внутри «50», «150», «5000»). $out = $guard->clean('Денег хватит примерно на 5 суток.', trapCard()); expect($out)->toBe(''); }); it('ЧЕСТНЫЕ ответы по той же карточке — НЕ режутся', function () { $guard = new App\Services\Bot\AnswerGuard; $card = trapCard(); expect($guard->clean('Сегодня пришло 2 заявки.', $card))->toBe('Сегодня пришло 2 заявки.') ->and($guard->clean('На балансе 3 400 ₽.', $card))->toBe('На балансе 3 400 ₽.') ->and($guard->clean('У вас 4 проекта.', $card))->toBe('У вас 4 проекта.') ->and($guard->clean('Получено в этом месяце 12 заявок.', $card))->toBe('Получено в этом месяце 12 заявок.') ->and($guard->clean('Цена сейчас 50 ₽ за заявку.', $card))->toBe('Цена сейчас 50 ₽ за заявку.'); }); it('без карточки в контексте (гость) правило «число↔факт» не работает — работает старое', function () { $guard = new App\Services\Bot\AnswerGuard; // Нет маркера «ДАННЫЕ ЭТОГО КЛИЕНТА» → парсер карточки вернёт null, а число 300, // которого нет в материалах вовсе, всё равно режется старым правилом. $out = $guard->clean('Сегодня пришло 300 заявок.', 'Статья про подбор конкурентов.'); expect($out)->toBe(''); }); it('без карточки: число ИЗ статьи (обычная, не клиентская) по-прежнему не режется', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean('Подбор конкурентов стоит 300 ₽.', 'Цена подбора конкурентов — 300 ₽ за запуск.'); expect($out)->toContain('300'); }); /** * 🔴 ЖИВОЙ БАГ 13.07.2026: правило владельца — ЗАМЕНЫ ЗАЯВКИ НЕТ (недозвон не брак), но * бот не должен РУБИТЬ ДВЕРЬЮ («не полагается», «не вернём»). Старое правило BANNED * (строка ~74) знало только форму ЕДИНСТВЕННОГО числа «не полагается» — «не полагаются» * (множественное) прошло мимо на живом прогоне. Урок проекта: правила по СПИСКУ * ФОРМУЛИРОВОК проигрывают гонку — нужно правило ПО СМЫСЛУ (как promisesReplacement и * соседи), а не очередная строка в BANNED. */ it('🔴 ЖИВОЙ БАГ: «Замены за недозвоны не полагаются» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Замены за недозвоны не полагаются — это нормальная часть работы.', 'Недозвон — обычная часть работы с контактами.' ); expect($out)->not->toContain('не полагаются'); }); it('старая форма «Замена не полагается» — тоже рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean('Замена не полагается.', 'Недозвон — обычная часть работы.'); expect($out)->toBe(''); }); it('«Возврат денег за недозвон не предусмотрен» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Возврат денег за недозвон не предусмотрен.', 'Недозвон — обычная часть работы.' ); expect($out)->toBe(''); }); it('«Компенсации за такие заявки мы не делаем» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Компенсации за такие заявки мы не делаем.', 'Недозвон — обычная часть работы.' ); expect($out)->toBe(''); }); it('«Деньги за недозвоны не возвращаются» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Деньги за недозвоны не возвращаются.', 'Недозвон — обычная часть работы.' ); expect($out)->toBe(''); }); it('«Заменить заявку нельзя, возврата нет» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Заменить заявку нельзя, возврата нет.', 'Недозвон — обычная часть работы.' ); expect($out)->toBe(''); }); it('«Мы не меняем и не возвращаем деньги за такие номера» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Мы не меняем и не возвращаем деньги за такие номера.', 'Недозвон — обычная часть работы.' ); expect($out)->toBe(''); }); it('«Такие заявки не подлежат замене» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Такие заявки не подлежат замене.', 'Недозвон — обычная часть работы.' ); expect($out)->toBe(''); }); it('«Возмещение по недозвонам не положено» — рубка дверью, вырезается', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Возмещение по недозвонам не положено.', 'Недозвон — обычная часть работы.' ); expect($out)->toBe(''); }); /** * Честные фразы — сгладить тему, но НЕ рубить дверью. Их резать НЕЛЬЗЯ: без них ответ * потеряет весь смысл и клиент уйдёт с пустым ответом (эскалация «оставьте телефон»). */ it('честное: путь на support@ для спорного случая — НЕ режется', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Спорный случай опишите на support@liderra.ru — специалист разберёт его индивидуально.', 'Поддержка работает круглосуточно.' ); expect($out)->toContain('support@liderra.ru') ->and($out)->toContain('специалист разберёт его индивидуально'); }); it('честное: «недозвон — обычная часть работы, попробуйте СМС и повтор» — НЕ режется', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Недозвон — обычная часть работы: попробуйте СМС и повторный звонок позже.', 'Недозвон — обычная часть работы.' ); expect($out)->toContain('попробуйте СМС и повторный звонок позже'); }); it('честное: «из ста номеров обычно отвечают 8–12 человек» — НЕ режется', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Из ста номеров обычно отвечают 8–12 человек.', 'Из ста номеров обычно отвечают 8–12 человек.' ); expect($out)->toContain('Из ста номеров обычно отвечают'); }); it('честное: «если заявка явно не по теме, напишите нам — разберёмся» — НЕ режется', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean( 'Если заявка явно не по теме, напишите нам — разберёмся.', 'Заявка не по теме — напишите нам.' ); expect($out)->toContain('напишите нам — разберёмся'); }); /** * Ложные срабатывания: слово «нет» — самое частое слово в русском, встречается в * контекстах, никак не связанных с заменой/возвратом/деньгами. Правило НЕ должно их резать. */ it('ложное срабатывание: «заявок сегодня нет» — про заявки, не про замену/деньги, НЕ режется', function () { $guard = new App\Services\Bot\AnswerGuard; $out = $guard->clean('Заявок сегодня нет — это нормально, бывают дни потише.', 'Заявок сегодня нет.'); expect($out)->toContain('Заявок сегодня нет'); }); it('ложное срабатывание: «денег на балансе нет» (не отказ в замене/возврате) — НЕ режется', function () { $guard = new App\Services\Bot\AnswerGuard; // Слово «деньг\w*» — триггер моего правила, «нет» — тоже, но смысл предложения не про // отказ в замене/возврате, а про то, что баланс пуст. Правило не должно это резать. $out = $guard->clean( 'Денег на балансе нет — пополните счёт, чтобы получать новые заявки.', 'Пополнить баланс можно картой или по счёту.' ); expect($out)->toContain('Денег на балансе нет'); }); /** * 🔴 ЖИВОЙ БАГ 13.07.2026 (живой разбор): в BANNED был кусок * «ограничени\w*\s+по\s+ниш» — он должен был ловить враньё «есть ограничения по нишам» * (D22), но по факту ловил ЛЮБОЕ соседство слов «ограничения» и «ниши» — включая ЧЕСТНУЮ * фразу «Ограничений по нишам нет» из resources/help/nishi-i-pravila.md. Сторож резал * правду, клиент получал обрубок вместо ясного ответа. * * Правило переписано ПО СМЫСЛУ (как priceTiedToNiche/promisesNicheRelevance по соседству): * «ограничения» + «ниши» рядом — враньё, ТОЛЬКО если в предложении нет отрицания * (нет / не существует / отсутствуют / никаких / без ограничений). */ it('честное «ограничений по нишам нет» (по nishi-i-pravila.md) — НЕ режется', function () { $guard = new App\Services\Bot\AnswerGuard; $context = 'Ограничений по нишам у нас нет.'; $cases = [ 'Ограничений по нишам нет — работаем с любым бизнесом.', 'Никаких ограничений по нишам у нас нет.', 'Ограничения по нишам отсутствуют.', 'По нишам ограничений не существует.', ]; foreach ($cases as $text) { expect($guard->clean($text, $context))->toBe($text); } }); it('враньё «есть ограничения по нишам» — по-прежнему режется', function () { $guard = new App\Services\Bot\AnswerGuard; $context = 'Ограничений по нишам у нас нет.'; expect($guard->clean('Есть ограничения по нишам — с некоторыми мы не работаем.', $context)) ->toBe('') ->and($guard->clean('Ограничения по нишам у нас есть.', $context)) ->toBe('') ->and($guard->clean('Мы работаем не со всеми нишами.', $context)) ->toBe('') ->and($guard->clean('Работаем не с каждым бизнесом.', $context)) ->toBe(''); });