From 3a07cb20598b9bb485d071fb02754b5ef6599874 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: Tue, 14 Jul 2026 18:51:13 +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=D1=80=D0=B5=D0=B7=D0=B0=D0=BB=20=D0=9F=D0=A0=D0=90?= =?UTF-8?q?=D0=92=D0=94=D0=A3=20=D0=BF=D1=80=D0=BE=20=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=20=E2=80=94=20=C2=AB=D0=BE=D1=82=201=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=B0=C2=BB=20=D1=81=D1=87=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=20=D0=B2=D1=8B=D0=B4=D1=83=D0=BC=D0=BA=D0=BE?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Живая проверка на боевом сразу после выката: «Можно купить всего 5 номеров?» — бот ответил обрубком «Платите только за то, что реально получите». Две мои же ошибки: 1. Правило про минимальный заказ было слишком грубым и резало ЧЕСТНУЮ формулировку «минимальный заказ — от 1 номера». Теперь режем только выдуманный минимум БОЛЬШЕ одного («минимальный заказ — 100 заявок», «пакет от 500 штук»). 2. Сторож собирал разрешённые числа ТОЛЬКО цифрами. В статье написано словом («продаём любой объём, хоть ОДНУ штуку»), бот ответил цифрой («от 1 номера») — и число 1 сочлось выдуманным. Теперь слово-число в материалах разрешает цифру в ответе; добавлены падежные формы (одну, одного, двух, трёх). Живьём: «Можно купить всего 5 номеров?» → «Минимального заказа нет — можно купить хоть одну заявку, хоть пять, хоть сколько угодно». Тесты: бот 259/259, весь бэкенд 2924/2928 (0 падений). Co-Authored-By: Claude Opus 4.8 --- app/app/Services/Bot/AnswerGuard.php | 35 +++++++++++++++++------ app/tests/Feature/Bot/Run250FixesTest.php | 13 +++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/app/app/Services/Bot/AnswerGuard.php b/app/app/Services/Bot/AnswerGuard.php index f1d76fbb..de8cd5ae 100644 --- a/app/app/Services/Bot/AnswerGuard.php +++ b/app/app/Services/Bot/AnswerGuard.php @@ -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)); } } diff --git a/app/tests/Feature/Bot/Run250FixesTest.php b/app/tests/Feature/Bot/Run250FixesTest.php index 0be4e738..461e8e07 100644 --- a/app/tests/Feature/Bot/Run250FixesTest.php +++ b/app/tests/Feature/Bot/Run250FixesTest.php @@ -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);