fix(bot): сторож ловит жёсткий отказ в замене в любой форме (живой баг 13.07)

This commit is contained in:
Дмитрий
2026-07-13 13:37:37 +03:00
parent 1804f38724
commit aa9537d43e
2 changed files with 212 additions and 0 deletions
+34
View File
@@ -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), никакого «рабочего дня» у нас нет это
+178
View File
@@ -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('Денег на балансе нет');
});