fix(bot): гостю больше нельзя приписывать его ступень, баланс и заявки

Проверка на боевом 14.07.2026 (сразу после выката фикса цен): гость — человек не вошёл,
никаких заявок у него нет — показал скриншот с 50 ₽, и бот ответил: «Вы сейчас на второй
ступени (50 ₽), потому что в этом месяце уже получили заявки». Выдумка про человека.

Причина: сторож сверяет личные цифры только у ВОШЕДШЕГО (карточка фактов). У гостя
карточки нет — и модель фантазировала свободно.

Теперь AnswerGuard знает, посчитаны ли личные цифры собеседника. Если нет (гость) —
режет фразы, утверждающие его нынешнее состояние: «вы сейчас на… ступени», «у вас
на балансе N», «вы уже получили заявки». Общие объяснения не трогаются: «вы платите
только за полученные заявки», «когда наберёте объём — перейдёте на следующую ступень».

Тесты бота: 242/242 (+2 новых).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-14 15:49:53 +03:00
parent 7b7718d319
commit a2bd2dbc8b
3 changed files with 87 additions and 2 deletions
+41 -1
View File
@@ -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));
+5 -1
View File
@@ -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);
}
@@ -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,