From aa9537d43ef8fdb8a0f5c037af397bc3838965cd 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: Mon, 13 Jul 2026 13:37:37 +0300 Subject: [PATCH] =?UTF-8?q?fix(bot):=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B6=20=D0=BB=D0=BE=D0=B2=D0=B8=D1=82=20=D0=B6=D1=91=D1=81?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=D0=B9=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20?= =?UTF-8?q?=D0=B2=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=B2=20=D0=BB?= =?UTF-8?q?=D1=8E=D0=B1=D0=BE=D0=B9=20=D1=84=D0=BE=D1=80=D0=BC=D0=B5=20(?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20=D0=B1=D0=B0=D0=B3=2013.07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/app/Services/Bot/AnswerGuard.php | 34 +++++ app/tests/Feature/Bot/AnswerGuardTest.php | 178 ++++++++++++++++++++++ 2 files changed, 212 insertions(+) diff --git a/app/app/Services/Bot/AnswerGuard.php b/app/app/Services/Bot/AnswerGuard.php index c6d2bba4..9f53d937 100644 --- a/app/app/Services/Bot/AnswerGuard.php +++ b/app/app/Services/Bot/AnswerGuard.php @@ -153,6 +153,7 @@ class AnswerGuard if ($this->isBanned($sentence) || $this->priceTiedToNiche($sentence) || $this->promisesReplacement($sentence) + || $this->refusesReplacementHarshly($sentence) || $this->promisesSupportSla($sentence) || $this->promisesNicheRelevance($sentence) || $this->hasFactMismatch($sentence, $facts) @@ -218,6 +219,39 @@ class AnswerGuard return $promise && $object && ! $denial; } + /** + * 🔴 ЖИВОЙ БАГ 13.07.2026: жёсткий отказ в замене/возврате в ЛЮБОЙ формулировке — не + * только «не полагается» (единственное число). Прогон поймал бота на «Замены за + * недозвоны НЕ ПОЛАГАЮТСЯ» (множественное число): старое BANNED-правило (см. константу + * выше) знало только форму единственного числа и требовало ≤30 символов между словами — + * «Замены ЗА НЕДОЗВОНЫ не полагаются» уже не влезало. Урок проекта: список формулировок + * проигрывает гонку. Здесь — правило ПО СМЫСЛУ, как у promisesReplacement/priceTiedToNiche + * и соседей: предмет (замена/возврат/компенсация/деньги) + отказ в любой форме, без + * ограничения на расстояние (предмет и отказ ищутся независимо — по всему предложению), + * и с исключением «мягкого пути» (support@/специалист/индивидуально), который снимает + * тон рубки дверью и должен выживать. + * + * НЕ ловим голые «нет»/«нельзя»/«отказ» — они слишком частые и без тесной привязки к + * предмету дают ложные срабатывания («денег на балансе нет» — про пустой баланс, не + * про отказ в возврате). Такие короткие сентенции уже режутся через promisesReplacement + * или старые BANNED-паттерны, где нужно. + */ + private function refusesReplacementHarshly(string $sentence): bool + { + $subject = preg_match('/(замен\w*|возврат\w*|компенсаци\w*|возмещ\w*|деньг\w*|средств\w*)/iu', $sentence) === 1; + $refusal = preg_match( + '/(не\s+полага\w+|не\s+положен\w*|не\s+предусмотрен\w*|невозможн\w*|не\s+делаем' + .'|не\s+возвраща\w*|не\s+верн[ёе]м|не\s+меня\w*)/iu', + $sentence + ) === 1; + $softPath = preg_match( + '/(support@liderra\.ru|специалист\w*\s+разбер\w*|индивидуальн\w*|напишите\s+нам)/iu', + $sentence + ) === 1; + + return $subject && $refusal && ! $softPath; + } + /** * Обещанный СРОК СВЯЗИ («перезвонят в течение рабочего дня», «ответим за пару часов»). * Поддержка круглосуточная (podderzhka.md), никакого «рабочего дня» у нас нет — это diff --git a/app/tests/Feature/Bot/AnswerGuardTest.php b/app/tests/Feature/Bot/AnswerGuardTest.php index a88ecd22..98ac1091 100644 --- a/app/tests/Feature/Bot/AnswerGuardTest.php +++ b/app/tests/Feature/Bot/AnswerGuardTest.php @@ -184,3 +184,181 @@ it('без карточки: число ИЗ статьи (обычная, не 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('Денег на балансе нет'); +});