004a0b1737
- «скидка ЗА ОБЪЁМ» больше не считается просьбой скидки: бот отвечает про ступени тарифа, а не уводит к специалисту. - «где посмотреть мою ступень / мой лимит» — вопрос про портал, а не про личные данные клиента: эскалация снята. - Цена ↔ ниша/регион: правило по СМЫСЛУ (AnswerGuard::priceTiedToNiche). Враньё «цена зависит от ниши» возвращалось ПЯТЬЮ формулировками — регулярка на каждую не работает. Честное «цена НЕ зависит от региона» сохраняется. - Вырезано: «на тысячу выйдет от 2 до 20 заявок» (реально максимум 4), «работаем не со всеми нишами» / «есть направления, с которыми мы не сотрудничаем» (ограничений по нишам НЕТ), «не берёт трубку — забей» (такую заявку МЕНЯЮТ), «баланс на паузе не потратится», выдуманные сроки зачисления по счёту. - Промпт: цена только от ступени объёма; запретных ниш нет. - Статьи: тронута ТОЛЬКО строка topics (синонимы поиска), тексты не менялись. Тесты 126/126. Бот НЕ на проде. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
14 KiB
PHP
200 lines
14 KiB
PHP
<?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] ?? []
|
||
)));
|
||
}
|
||
}
|