fix(bot): сторож резал ПРАВДУ про минимальный заказ — «от 1 номера» считал выдумкой

Живая проверка на боевом сразу после выката: «Можно купить всего 5 номеров?» — бот
ответил обрубком «Платите только за то, что реально получите». Две мои же ошибки:

1. Правило про минимальный заказ было слишком грубым и резало ЧЕСТНУЮ формулировку
   «минимальный заказ — от 1 номера». Теперь режем только выдуманный минимум БОЛЬШЕ
   одного («минимальный заказ — 100 заявок», «пакет от 500 штук»).
2. Сторож собирал разрешённые числа ТОЛЬКО цифрами. В статье написано словом («продаём
   любой объём, хоть ОДНУ штуку»), бот ответил цифрой («от 1 номера») — и число 1 сочлось
   выдуманным. Теперь слово-число в материалах разрешает цифру в ответе; добавлены
   падежные формы (одну, одного, двух, трёх).

Живьём: «Можно купить всего 5 номеров?» → «Минимального заказа нет — можно купить хоть
одну заявку, хоть пять, хоть сколько угодно».

Тесты: бот 259/259, весь бэкенд 2924/2928 (0 падений).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-14 18:51:13 +03:00
parent b1b1686b0e
commit 3a07cb2059
2 changed files with 39 additions and 9 deletions
+26 -9
View File
@@ -108,10 +108,12 @@ class AnswerGuard
// статьях этого нет вовсе) и делил одно на два — клиент слышит отказ там, где отказа нет.
'/не\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',
// NB: «минимального заказа НЕТ» и «минимальный заказ — одна заявка» — ПРАВДА, резать
// нельзя (живая проверка на боевом 14.07.2026: сторож съел верный ответ, остался
// обрубок «Платите только за то, что получите»). Ловим только выдуманный минимум
// БОЛЬШЕ ОДНОГО: «минимальный заказ — 100 заявок», «пакет от 500 штук».
'/минимальн\w+\s+(объ[её]м|заказ|партия|пакет)\w*[^.!?]{0,20}(?!1\b)([2-9]|\d{2,})/iu',
'/(пакет\w*|партия\w*)\s+от\s+(?!1\b)([2-9]|\d{2,})/iu',
// 🔴 Владелец 14.07.2026: сколько заявок выйдет на подарочную 1000 ₽ — НЕ ОБЕЩАЕМ и
// числа не называем (besplatnyy-test.md). Сколько придёт, зависит от источника, а на
// тесте по своему сайту их обычно единицы. «Несколько десятков» — враньё в лицо.
@@ -129,11 +131,14 @@ class AnswerGuard
* Числа словами. Модель их не всегда переводит в цифры («у вас два проекта» вместо
* «2 проекта»), а клиенту от формы всё равно считаем так же, как цифру.
*/
// NB: падежные формы обязательны. Живая проверка 14.07.2026: в статье написано «продаём
// любой объём, хоть ОДНУ штуку», а бот отвечал «от 1 номера» — сторож не знал формы
// «одну», числа 1 в разрешённых не увидел и вырезал ПРАВДУ, оставив обрубок.
private const WORD_NUMBERS = [
'ноль' => '0',
'один' => '1', 'одна' => '1', 'одно' => '1',
'два' => '2', 'две' => '2',
'три' => '3',
'один' => '1', 'одна' => '1', 'одно' => '1', 'одну' => '1', 'одного' => '1', 'одной' => '1',
'два' => '2', 'две' => '2', 'двух' => '2',
'три' => '3', 'трёх' => '3', 'трех' => '3',
'четыре' => '4',
'пять' => '5',
'шесть' => '6',
@@ -770,9 +775,21 @@ class AnswerGuard
{
preg_match_all('/\d[\d\s]{0,9}\d|\d/u', $context, $matches);
return array_values(array_unique(array_map(
$numbers = array_map(
fn (string $n) => (string) preg_replace('/\s+/u', '', $n),
$matches[0] ?? []
)));
);
// 🔴 Живая проверка на боевом 14.07.2026: в статье написано словом («продаём любой
// объём, хоть ОДНУ штуку»), бот ответил цифрой («от 1 номера») — и сторож вырезал
// ПРАВДУ, потому что видел в материалах только цифры. Слово-число в материалах —
// такое же разрешение, как цифра.
$wordAlt = implode('|', array_keys(self::WORD_NUMBERS));
preg_match_all('/\b('.$wordAlt.')\b/iu', $context, $words);
foreach ($words[1] ?? [] as $word) {
$numbers[] = $this->normalizeNumberToken($word);
}
return array_values(array_unique($numbers));
}
}
+13
View File
@@ -127,6 +127,19 @@ it('число, названное самим клиентом, сторож н
expect($guard->clean($text, $context, personalFactsKnown: false))->toContain('5 номеров');
});
it('правду «минимальный заказ — одна заявка» сторож не режет, а выдуманный минимум режет', function () {
// Живая проверка на боевом 14.07.2026: сторож съел верный ответ про 5 номеров, остался
// обрубок «Платите только за то, что получите». Виновата была моя же грубая регулярка.
$guard = app(AnswerGuard::class);
$ctx = 'Минимального заказа нет: продаём любой объём, хоть одну штуку.';
$true = 'Да, конечно. Минимальный заказ — от 1 номера, берите сколько нужно.';
expect($guard->clean($true, $ctx, personalFactsKnown: false))->toContain('от 1 номера');
$lie = 'Минимальный заказ — 100 заявок в месяц.';
expect($guard->clean($lie, $ctx, personalFactsKnown: false))->not->toContain('100 заявок');
});
it('сколько заявок выйдет на подарочную тысячу — бот не обещает', function () {
$guard = app(AnswerGuard::class);