Files
portal/app/tests/Feature/Bot/AnswerGuardTest.php
T
Дмитрий c3b5cfce5b fix(bot): сторож проверяет число↔факт — перепутанные цифры из карточки больше не проходят
Ревьюер прогнал AnswerGuard руками: пропускал числа словами («два проекта»),
разрыв «число...единица» («20 в день»), чужие единицы (сделок/клиент/сутки/...)
и самое опасное — число, которое в карточке ЕСТЬ, но относится к ДРУГОМУ факту
(модель переставила местами). Карточку (ClientFacts) генерирует код с
предсказуемым форматом, поэтому сторож теперь разбирает её на пары
«факт → правильное число» (баланс, цена ступени, получено/до след. ступени,
кол-во проектов, заявки сегодня/вчера/7дней/месяц, списано) и режет
несовпадение по смыслу, а не по буквальной формулировке.

TDD: 9 новых тестов (196→195 итого с учётом снятого дубля), 195/195 зелёных.
Стресс-прогон 20 предложений (10 враньё + 10 честных) вручную — 0 расхождений
после фикса дизамбигуации «этот/этом месяц» vs голое «за месяц».
2026-07-13 12:40:54 +03:00

187 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Bot\AnswerGuard;
/**
* Сторож ответа (финальное сравнение моделей 12.07.2026). Умная модель звучит живо, но
* срывается: обещает «Нет, не оштрафуют», «мы гарантируем», выдумывает цены. Промпт её
* не держит — держим кодом: запрещённое вырезаем ДО отправки клиенту.
*/
it('вырезает юридические гарантии', function () {
$guard = app(AnswerGuard::class);
$answer = $guard->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');
});