В настройках бота было два лимита на длину диалога. Ни один из них ни разу не сработал
В админке Octery у каждой компании есть две настройки, которые ограничивают разговор бота с клиентом. Max Messages: сколько ответов бот даёт, прежде чем позвать менеджера. Hard Stop Threshold: сколько бессмысленных сообщений подряд он терпит, прежде чем сделать то же самое. 30 сентября выяснилось, что не работает ни та, ни другая, и, похоже, не работала с самого начала.
Octery это бот, который отвечает лидам в чатах amoCRM и передаёт сделку менеджеру. Лимиты нужны ему по двум причинам. Если человек задал уже двадцатый вопрос, ему пора к живому специалисту, бот своё дело сделал. А если в чат пишут «ааа», «тест», «???» или там вообще другой бот, каждое такое сообщение стоит нам вызова модели, и отвечать на них бесконечно незачем.
Две ошибки, которые прикрывали друг друга
Решение «передать менеджеру» принималось после ответа модели, в пайплайне обработки сообщения. Условие собиралось из трёх проверок, и между ними стояло И. По смыслу настроек это два независимых лимита: передавать, если исчерпан счётчик ответов, или если набралась серия бессмыслицы. С И бот звал менеджера, только когда клиент одновременно исчерпал лимит сообщений и при этом последние несколько раз подряд писал ерунду. Такого не происходило.
Вторая ошибка сидела в ветке, которая должна была выполниться, когда условие сработало. Она переписывала ответ на фразу «передаю менеджеру» и меняла намерение диалога на manager. Только записывала она это в поля, у которых вместо имён стояли выражения: значение вставили в поле имени, и оно так и осталось. А следующие шаги, отправка ответа и логирование, читали ответ и намерение не из этой ветки, а напрямую из разбора ответа модели, который был раньше. Даже исправь кто-то И на ИЛИ, клиент получил бы обычный ответ бота, а в логах не осталось бы следа.
Каждую из ошибок по отдельности нашёл бы первый же ручной прогон. Вместе они делали лимиты просто невидимыми: настройка сохранялась, в интерфейсе стояли цифры, бот работал как обычно.
Почему никто не заметил
Потому что проверить это можно было только одним способом: дойти в живом диалоге до лимита и посмотреть, что будет. Логика жила в конфигурации пайплайна, а не в коде. Юнит-тестов на конфигурацию не напишешь, в CI её не прогонишь, а на ревью изменение в ней выглядит как «поправил условие», без диффа, который можно прочитать.
Доводить тестовый диалог до лимита руками долго, и при обычных проверках до него не доходили. Так что на вопрос «работают ли лимиты» у нас не было ни одного ответа, ни положительного, ни отрицательного.
Что сделали
Решение по сообщению переехало в наш API отдельной чистой функцией. Пайплайн передаёт ей ответ модели и два счётчика из состояния диалога, а лимиты функция берёт из настроек компании сама. Это сделано намеренно: тот, кто вызывает функцию, не может подсунуть ей другие лимиты. На выходе одно итоговое решение: текст, намерение, причина передачи, и все дальнейшие шаги читают только его.
Формулы стоит записать явно, потому что в них две ловушки на единицу:
// счётчик ответов считается до текущего сообщения:
// бот даёт ровно max_messages ответов, потом передаёт
const byMessages = messageCounter >= maxMessages;
// серия бессмыслицы считается вместе с текущим сообщением,
// так написано в подсказке к настройке в интерфейсе
const byNonsense = !meaningful && nonMeaningfulStreak + 1 >= hardStopThreshold;
if (byMessages || byNonsense) { /* передать менеджеру */ }
Если выполнены оба условия сразу, причиной пишется лимит сообщений. Когда шаблон фразы у компании пустой, уходит фраза по умолчанию. И ещё одна мелочь: старая ветка при передаче заодно ставила сообщению оценку «бессмысленное», что портило аналитику. Теперь оценка остаётся той, которую дала модель.
Тесты из спецификации
Поведение сначала описали сценариями в спецификации, а потом каждый сценарий стал тестом. Их 16, вот часть названий как есть:
passes the agent answer through when no limit is reached
hands off once max_messages were already processed, even for a meaningful message
still answers with the agent on the last allowed message
hands off on the Nth non-meaningful message in a row, counting the current one
does not hand off on the first non-meaningful message
a meaningful message breaks the non-meaningful streak
hands off when both limits are reached (old AND case)
uses the default phrase when consult_phrase_template is empty
Тест с пометкой «old AND case» проверяет единственный случай, в котором старое условие вообще срабатывало (до сломанной ветки дело всё равно не доходило). Я оставил его специально: старое условие входит в новое как частный случай.
Остальные сценарии описывают то, что клиенты видели в настройках и считали работающим. Отсюда и главный риск выката. Компании с маленькими лимитами впервые увидят, как бот замолкает и зовёт менеджера, и для них это будет выглядеть как новая функция, а не как исправление. Поэтому перед включением на проде их надо предупредить, и это записано в плане выката отдельной строкой.
Сама ошибка банальная, И вместо ИЛИ, такое бывает у всех. Меня в этой истории больше задело другое: у настройки, которую видит клиент, не было ни одной автоматической проверки. Теперь правило простое: если цифра стоит в интерфейсе, у её поведения есть тест, и тест называется так, чтобы по названию было понятно, что обещано клиенту.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.