From a2bd2dbc8bb9b25dca79d20b008134acaef56d53 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 15:49:53 +0300 Subject: [PATCH] =?UTF-8?q?fix(bot):=20=D0=B3=D0=BE=D1=81=D1=82=D1=8E=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D1=8F=20=D0=BF=D1=80=D0=B8=D0=BF=D0=B8=D1=81=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B5=D0=B3=D0=BE=20=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=B5=D0=BD=D1=8C,=20=D0=B1=D0=B0=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D1=81=20=D0=B8=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка на боевом 14.07.2026 (сразу после выката фикса цен): гость — человек не вошёл, никаких заявок у него нет — показал скриншот с 50 ₽, и бот ответил: «Вы сейчас на второй ступени (50 ₽), потому что в этом месяце уже получили заявки». Выдумка про человека. Причина: сторож сверяет личные цифры только у ВОШЕДШЕГО (карточка фактов). У гостя карточки нет — и модель фантазировала свободно. Теперь AnswerGuard знает, посчитаны ли личные цифры собеседника. Если нет (гость) — режет фразы, утверждающие его нынешнее состояние: «вы сейчас на… ступени», «у вас на балансе N», «вы уже получили заявки». Общие объяснения не трогаются: «вы платите только за полученные заявки», «когда наберёте объём — перейдёте на следующую ступень». Тесты бота: 242/242 (+2 новых). Co-Authored-By: Claude Opus 4.8 --- app/app/Services/Bot/AnswerGuard.php | 42 ++++++++++++++++++- app/app/Services/Bot/BotAnswerService.php | 6 ++- .../Feature/Bot/LiveDialogPriceFixesTest.php | 41 ++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/app/app/Services/Bot/AnswerGuard.php b/app/app/Services/Bot/AnswerGuard.php index 3a7819a4..808e1e85 100644 --- a/app/app/Services/Bot/AnswerGuard.php +++ b/app/app/Services/Bot/AnswerGuard.php @@ -142,7 +142,13 @@ class AnswerGuard * @param string $context фрагменты статей, которые модель получила: цены, названные * в ответе, обязаны встречаться здесь — иначе они выдуманы */ - public function clean(string $text, string $context): string + /** + * @param bool $personalFactsKnown мы знаем, КТО пишет, и посчитали его цифры (карточка + * фактов). У гостя с лендинга их нет — и тогда любое + * «вы сейчас на второй ступени» это выдумка про + * человека (проверка на боевом 14.07.2026). + */ + public function clean(string $text, string $context, bool $personalFactsKnown = true): string { $sentences = preg_split('/(?<=[.!?])\s+/u', trim($text)) ?: []; $allowed = $this->numbersIn($context); @@ -158,6 +164,7 @@ class AnswerGuard || $this->refusesReplacementHarshly($sentence) || $this->promisesSupportSla($sentence) || $this->promisesNicheRelevance($sentence) + || (! $personalFactsKnown && $this->claimsPersonalState($sentence)) || $this->hasFactMismatch($sentence, $facts) || $this->hasInventedNumber($sentence, $allowed)) { $cutSomething = true; @@ -191,6 +198,39 @@ class AnswerGuard * понять…», отговорку сняли — и клиент получил ответ с середины фразы, решил, что * ему хамят: «Ну хамить то не надо!». */ + /** + * Фраза утверждает ЛИЧНОЕ состояние собеседника: его ступень, баланс, объём заявок. + * + * Гостю такое говорить нельзя ни при каких обстоятельствах: он не вошёл, никаких + * заявок у него нет, и цифры взять неоткуда. Проверка на боевом 14.07.2026: гость + * показал скриншот с 50 ₽ — бот ответил «Вы сейчас на второй ступени, потому что в + * этом месяце уже получили заявки». Это выдумка про человека. + * + * Общие объяснения не трогаем: «вы платите только за полученные заявки», «когда + * наберёте объём — перейдёте на следующую ступень» — тут нет утверждения о том, что + * у человека происходит ПРЯМО СЕЙЧАС. + */ + private function claimsPersonalState(string $sentence): bool + { + $aboutHim = preg_match('/\b(вы|вас|вам|ваш\w*)\b/iu', $sentence) === 1; + if (! $aboutHim) { + return false; + } + + $subject = preg_match('/(ступен\w*|баланс\w*|тариф\w*|объ[её]м\w*|заяв\w*|списан\w*)/iu', $sentence) === 1; + if (! $subject) { + return false; + } + + // «сейчас/уже/находитесь/текущая» — маркеры утверждения о нынешнем состоянии. + $rightNow = preg_match('/(сейчас|уже|находит\w*|текущ\w*|на данный момент|в этом месяце)/iu', $sentence) === 1; + + // «у вас на балансе 11 025 ₽» — притяжательное + число, даже без «сейчас». + $ownedNumber = preg_match('/(у\s+вас|ваш\w*)[^.!?]{0,40}\d/iu', $sentence) === 1; + + return $rightNow || $ownedNumber; + } + public function polishStart(string $text): string { $text = (string) preg_replace(self::DANGLING_START, '', trim($text)); diff --git a/app/app/Services/Bot/BotAnswerService.php b/app/app/Services/Bot/BotAnswerService.php index be3257d2..0c3fe96a 100644 --- a/app/app/Services/Bot/BotAnswerService.php +++ b/app/app/Services/Bot/BotAnswerService.php @@ -325,7 +325,11 @@ class BotAnswerService array_filter($history, fn ($m) => ($m['role'] ?? '') === 'assistant') )); - $text = $this->guard->clean($text, $context."\n\n".$said); + // Гостю с лендинга личные цифры приписывать НЕЛЬЗЯ: он не вошёл, заявок и ступени + // у него нет. Карточка фактов есть только у вошедшего — по ней и решаем. + $personalFactsKnown = $facts !== null && trim($facts) !== ''; + + $text = $this->guard->clean($text, $context."\n\n".$said, $personalFactsKnown); if ($text === '') { return new BotAnswer(self::ESCALATE_TEXT, escalate: true); } diff --git a/app/tests/Feature/Bot/LiveDialogPriceFixesTest.php b/app/tests/Feature/Bot/LiveDialogPriceFixesTest.php index ee3326fd..edcbba5a 100644 --- a/app/tests/Feature/Bot/LiveDialogPriceFixesTest.php +++ b/app/tests/Feature/Bot/LiveDialogPriceFixesTest.php @@ -203,6 +203,47 @@ it('уверенным считается совпадение хотя бы н expect($search->confidentTopicMatch($sbor, 'сколько стоит сбор источников?'))->toBeTrue(); }); +it('гостю бот не приписывает его ступень и баланс — у гостя их нет', function () { + twoPricingGrids(); + + KnowledgeChunk::create([ + 'source_path' => 'help/tarify.md', 'title' => 'Тарифы — сколько стоит заявка', 'tour' => null, + 'topics' => 'тариф, цена, ступень, сколько стоит заявка', 'chunk_index' => 0, + 'content' => 'Всего 7 ступеней. Сейчас сетка такая: {{тарифы}}.', + ]); + + // Проверка на боевом 14.07.2026: гостю (он не вошёл, заявок у него нет) бот заявил + // «Вы сейчас на второй ступени (50 ₽), потому что уже получили заявки». Выдумка. + llmAnswers('Вы сейчас на второй ступени (50 ₽), потому что в этом месяце уже получили заявки. На второй ступени цена заявки — 50 ₽, дальше она опускается.'); + askBot('c-guest', 'Сколько стоит заявка на второй ступени?'); + + $answer = (string) BotDialog::where('chat_id', 'c-guest')->where('direction', 'out')->value('message'); + + // Личное состояние — вырезано. + expect($answer)->not->toContain('Вы сейчас на второй ступени') + ->and($answer)->not->toContain('уже получили заявки'); + + // Безличное объяснение про сетку — осталось, человек не остался без ответа. + expect($answer)->toContain('50 ₽'); +}); + +it('вошедшему клиенту его личные цифры называть можно — они из карточки фактов', function () { + twoPricingGrids(); + + KnowledgeChunk::create([ + 'source_path' => 'help/balans.md', 'title' => 'Баланс', 'tour' => null, + 'topics' => 'баланс, сколько денег', 'chunk_index' => 0, + 'content' => 'Баланс виден в разделе «Биллинг».', + ]); + + $guard = app(App\Services\Bot\AnswerGuard::class); + $facts = "ФАКТЫ КЛИЕНТА:\nбаланс: 11 025 ₽\nступень: 2"; + + $clean = $guard->clean('У вас сейчас на балансе 11 025 ₽.', $facts, personalFactsKnown: true); + + expect($clean)->toContain('11 025 ₽'); +}); + it('ответ не начинается с середины фразы, когда снята отговорка «в инструкции этого нет»', function () { KnowledgeChunk::create([ 'source_path' => 'help/o-servise.md', 'title' => 'О сервисе', 'tour' => null,