Files
portal/app/app/Services/Bot/AnswerGuard.php
T
Дмитрий 004a0b1737 fix(bot): ювелирная доводка после второго круга судей
- «скидка ЗА ОБЪЁМ» больше не считается просьбой скидки: бот отвечает про
  ступени тарифа, а не уводит к специалисту.
- «где посмотреть мою ступень / мой лимит» — вопрос про портал, а не про
  личные данные клиента: эскалация снята.
- Цена ↔ ниша/регион: правило по СМЫСЛУ (AnswerGuard::priceTiedToNiche).
  Враньё «цена зависит от ниши» возвращалось ПЯТЬЮ формулировками —
  регулярка на каждую не работает. Честное «цена НЕ зависит от региона»
  сохраняется.
- Вырезано: «на тысячу выйдет от 2 до 20 заявок» (реально максимум 4),
  «работаем не со всеми нишами» / «есть направления, с которыми мы не
  сотрудничаем» (ограничений по нишам НЕТ), «не берёт трубку — забей»
  (такую заявку МЕНЯЮТ), «баланс на паузе не потратится», выдуманные сроки
  зачисления по счёту.
- Промпт: цена только от ступени объёма; запретных ниш нет.
- Статьи: тронута ТОЛЬКО строка topics (синонимы поиска), тексты не менялись.

Тесты 126/126. Бот НЕ на проде.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 05:35:11 +03:00

200 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services\Bot;
/**
* Последний рубеж перед отправкой ответа клиенту.
*
* Финальное сравнение моделей 12.07.2026 показало: сильная модель звучит живо, но
* срывается на запретном — «Нет, вас не оштрафуют», «мы гарантируем», выдуманные цены
* («при 100 заявках — 450 ₽»). Запреты в промпте она соблюдает не всегда, поэтому
* режем на выходе: предложение с нарушением выбрасываем целиком.
*
* Пусто после чистки → BotAnswerService зовёт живого специалиста.
*/
class AnswerGuard
{
/**
* Запрещённые заявления. Юридические гарантии и обещания за клиента — то, чего
* компания не имеет права говорить в чате.
*/
private const BANNED = [
'/не оштрафу|вас не накажут|штраф\w* не будет/iu',
'/(мы|я)\s+гарантиру|гарантиру\w+\s+(результат|продаж|доход)/iu',
'/не нарушаем|нарушени\w* нет|в полном соответствии/iu',
'/(всё|все)\s+(абсолютно\s+)?законно(?![\s,]*—)/iu',
'/риск\w*\s+минимал/iu',
// Прогон диалогами 12.07.2026: юр-гарантии пролезли в НОВЫХ формулировках —
// «Да, законно», «Штраф не прилетит за сам звонок», «Всё по закону, без бумаг».
'/^\s*(да|нет)[\s,—-]+законно/iu',
'/\b(это|всё|все|вс[ёе])\s+законно\b/iu',
'/штраф\w*\s+не\s+(прилет|грозит|светит)/iu',
'/не\s+(прилетит|грозит)\s+(?:\S+\s+){0,2}штраф/iu',
'/(всё|все)\s+по\s+(федеральному\s+)?закону/iu',
// Телефона поддержки НЕ СУЩЕСТВУЕТ (podderzhka.md: только чат и почта). Бот
// трижды за прогон отправил клиента «позвонить в поддержку» — искать нечего.
'/(позвон\w+|звон\w+|наберите|набрать)\s+(?:\S+\s+){0,3}(в\s+)?(поддержк|support|нам)/iu',
'/(телефон|номер)\s+(?:\S+\s+){0,2}поддержк/iu',
// 🔴 ВРАНЬЁ ПРО ДЕНЬГИ (повтор 12.07.2026, D08): «на паузе списаний нет». По
// pauza-proekta.md уже заказанные заявки дойдут и будут ОПЛАЧЕНЫ. Клиент строит
// на этом решение — цена ошибки его деньги.
// NB: контрольный прогон 12.07.2026 — то же враньё пришло ДРУГИМИ словами
// («на паузе заявки не приходят, значит, и платить не за что»), где слова
// «списание» нет вовсе. Ловим смысл, а не формулировку.
'/пауз\w*[^.!?]{0,70}(списан\w*\s+нет|не\s+(будет|будут)\s+списан|не\s+спиш|не\s+списыв|списан\w*\s+не\s+идут|деньги\s+не\s+спис|платить\s+не\s+за\s+что|не\s+платите)/iu',
'/(списан\w*\s+нет|не\s+(будет|будут)\s+списан|не\s+спиш|не\s+списыв|платить\s+не\s+за\s+что)[^.!?]{0,50}пауз/iu',
// «Деньги начнут списываться, только когда запустите обратно» — то же обещание.
'/(списыв|спиш)\w*[^.!?]{0,40}(только\s+)?когда[^.!?]{0,30}(запустите|возобнов|включите)/iu',
// Цена зависит ТОЛЬКО от ступени объёма (tarify.md). «Зависит от ниши и региона» —
// выдумка, на которой клиент строит расчёт (повтор 12.07.2026, D46).
// Цена и ниша/регион не должны стоять рядом НИ В КАКОМ порядке: враньё приходило
// и как «цена зависит от ниши», и как «зависит от вашей ниши — цена разная».
// Связка «цена ↔ ниша/регион» вынесена в priceTiedToNiche(): она приходила пятью
// разными формулировками, гоняться за каждой регуляркой бессмысленно.
// «На тысячу выйдет от 2 до 20 заявок» — при минимальной ступени 250 ₽ это враньё
// (финал 12.07.2026, D46). Считать за клиента, сколько заявок выйдет, бот не должен.
'/на\s+(эту\s+)?(тысячу|1\s?000\s*₽?)[^.!?]{0,60}\d+[^.!?]{0,20}заяв/iu',
'/\d+\s*заяв\w*\s*(до|—||-)\s*\d+/iu',
// 18:00 — граница вступления в силу ИЗМЕНЕНИЙ (pravilo-18-00.md), а не время,
// когда заявки перестают приходить (повтор 12.07.2026, D33).
'/заявк\w*[^.!?]{0,30}(перестают|прекращают|не)\s+приход\w*[^.!?]{0,20}(в\s+)?18/iu',
'/(после|с)\s+18[:.]?0?0?[^.!?]{0,30}заявк\w*\s+(не\s+)?(приход|идут|нет)/iu',
// «Ночью заявок не бывает», «поток идёт только днём» — выдумка: в статьях
// расписания приёма заявок нет (контрольный прогон 12.07.2026, D33).
'/ночью[^.!?]{0,30}заяв\w*[^.!?]{0,25}(не\s+бывает|не\s+приход|нет)/iu',
'/(поток|заявк\w*)[^.!?]{0,30}только\s+дн[её]м/iu',
// «Не берёт трубку — значит, не заинтересован, гонять смысла нет»: по
// zamena-zayavki.md такую заявку МЕНЯЮТ. Совет бота стоит клиенту денег (D04).
'/(не\s+бер[её]т\s+трубку|не\s+отвечает|автоответчик)[^.!?]{0,60}(не\s+заинтересован|смысла\s+нет|не\s+стоит|заб\w+|бесполезн)/iu',
'/(не\s+заинтересован|смысла\s+нет\s+звонить)[^.!?]{0,40}(не\s+бер[её]т|не\s+отвеча)/iu',
// «Работаем не со всеми нишами / есть ограничения по нишам» — прямое противоречие
// nishi-i-pravila.md («Ограничений по нишам у нас нет») (D22).
// NB: враньё про ниши приходило ЧЕТЫРЬМЯ разными формулировками — последняя вообще
// без слова «ниши» рядом («работаем с разными нишами, но не со всеми»). Ловим смысл.
'/(не\s+со\s+всеми\s+ниш|ограничени\w*\s+по\s+ниш|есть\s+ограничения[^.!?]{0,25}ниш|работаем\s+не\s+с\s+кажд|не\s+со\s+всеми\s+подряд)/iu',
'/(ниш|сфер)\w*[^.!?]{0,40}(но\s+)?не\s+со\s+всеми/iu',
'/(есть\s+)?направлени\w*[^.!?]{0,40}не\s+(сотруднича|работа)\w*/iu',
'/не\s+(сотруднича|работа)\w*[^.!?]{0,30}(вообще|с\s+некотор|с\s+рядом|с\s+определ)/iu',
// Пауза, третья формулировка: «баланс при этом не потратится / не тронется»
// (финал 12.07.2026, D08). Уже заказанные заявки будут оплачены.
'/баланс[^.!?]{0,30}(не\s+потрат|не\s+тронет|не\s+уйд[её]т|останется\s+нетронут)/iu',
// Сроки зачисления по счёту («обычно 1–3 рабочих дня») — в статьях срока НЕТ.
'/(зачислен\w*|зачисл\w+|поступ\w+|деньги\s+прид\w+)[^.!?]{0,40}\d[^.!?]{0,15}(рабочих\s+дн|дн[ея])/iu',
];
/** Висящие союзы в начале — след от вырезанного предложения. */
private const DANGLING_START = '/^(но|а|однако|зато|при этом)\b[\s,]*/iu';
/** «Да», «Нет», «Правильно, не будут» — подтверждение без содержания. */
private const BARE_YES_NO = '/^\s*(да|нет|верно|правильно|именно так|совершенно верно|конечно)\b[^.!?]{0,25}[.!?]?\s*$/iu';
/**
* Чистит ответ модели.
*
* @param string $context фрагменты статей, которые модель получила: цены, названные
* в ответе, обязаны встречаться здесь — иначе они выдуманы
*/
public function clean(string $text, string $context): string
{
$sentences = preg_split('/(?<=[.!?])\s+/u', trim($text)) ?: [];
$allowed = $this->numbersIn($context);
$kept = [];
$cutSomething = false;
foreach ($sentences as $sentence) {
if ($this->isBanned($sentence)
|| $this->priceTiedToNiche($sentence)
|| $this->hasInventedPrice($sentence, $allowed)) {
$cutSomething = true;
continue;
}
$kept[] = $sentence;
}
// Вырезали ложь, а перед ней осталось голое «Правильно, не будут» — обрубок,
// который врёт ровно так же (повтор 12.07.2026, D08). Такие «поддакивания»
// без содержания снимаем вместе с ложью.
if ($cutSomething) {
$kept = array_values(array_filter(
$kept,
fn (string $s) => preg_match(self::BARE_YES_NO, $s) !== 1
));
}
$text = trim(implode(' ', $kept));
// Вырезали первое предложение — следующее часто начинается с «Но…»/«А…», и клиент
// читает обрубок (повтор 12.07.2026). Снимаем висящий союз и возвращаем заглавную.
$text = (string) preg_replace(self::DANGLING_START, '', $text);
return $text === '' ? '' : mb_strtoupper(mb_substr($text, 0, 1)).mb_substr($text, 1);
}
/**
* Цена привязана к нише/региону/источнику — такой зависимости НЕТ (tarify.md: цена
* зависит только от ступени объёма). Враньё приходило пятью формулировками подряд
* («цена зависит от ниши», «зависит от вашей ниши — цена разная», «цена заявки
* в вашей нише»), поэтому правило по смыслу: цена и ниша не стоят рядом.
*
* Исключение — честное отрицание («цена НЕ зависит от региона»): его резать нельзя.
*/
private function priceTiedToNiche(string $sentence): bool
{
$hasPrice = preg_match('/(цен\w*|стоимост\w*|дешевле|дороже)/iu', $sentence) === 1;
$hasNiche = preg_match('/(ниш\w*|регион\w*|категори\w*|тематик\w*|город\w*|направлени\w*|сфер\w*)/iu', $sentence) === 1;
$isDenial = preg_match('/(не\s+зависит|одинаков\w*|независимо\s+от|не\s+связан\w*|не\s+ограничива\w*|не\s+разн\w*)/iu', $sentence) === 1;
return $hasPrice && $hasNiche && ! $isDenial;
}
private function isBanned(string $sentence): bool
{
foreach (self::BANNED as $pattern) {
if (preg_match($pattern, $sentence) === 1) {
return true;
}
}
return false;
}
/**
* Цена (число рядом с ₽ или «рублей»), которой нет в выданных модели фрагментах, —
* выдумка. Цены живые, меняются в настройках: врать про них нельзя.
*
* @param list<string> $allowed
*/
private function hasInventedPrice(string $sentence, array $allowed): bool
{
if (preg_match_all('/(\d[\d\s]{0,9}\d|\d)\s*(?:₽|руб\w*)/u', $sentence, $matches) !== 1 * 0) {
foreach ($matches[1] ?? [] as $number) {
$clean = (string) preg_replace('/\s+/u', '', $number);
if ($clean !== '' && ! in_array($clean, $allowed, true)) {
return true;
}
}
}
return false;
}
/**
* Все числа, встречающиеся в статьях (не только рядом с ₽): модель может назвать
* ступень «500» из таблицы тарифов, где рубли стоят в заголовке колонки.
*
* @return list<string>
*/
private function numbersIn(string $context): array
{
preg_match_all('/\d[\d\s]{0,9}\d|\d/u', $context, $matches);
return array_values(array_unique(array_map(
fn (string $n) => (string) preg_replace('/\s+/u', '', $n),
$matches[0] ?? []
)));
}
}