Чат-бот своими руками: история одного велосипеда



Привет, хабр! Сегодня я расскажу о том, как своими руками с нуля собирались мозги для чат-бота, умеющего создавать резюме на основе беседы с человеком. Речь пойдет о том, как развивался написанный для этого дела велосипед, какие трудности встречал на своем пути и как изменялся в целях преодоления этих трудностей. Все описанные события происходили в процессе моего обучения в Школе программистов HeadHunter в 2017 году. Кому интересно — добро пожаловать под кат.

Предыстория


Частью (возможно, самой важной) обучения в ШП HeadHunter является разработка командного проекта. Наша команда писала бота для Telegram, который опрашивал бы пользователя, составлял бы на основе его ответов резюме и публиковал бы его на hh.ru. Задачи мы распределили между собой так, что один человек в основном занимался оберткой над API telegram и использованием его наиболее крутых фич, другой занимался CI/CD, базой данных и прочими вещами, а мне достались сами мозги бота. За бортом этой статьи оставлю проблемы недостатка времени, скилов и качества командного взаимодействия: они были, и немалые, но статья не об этом. Также пропущу описание обработки служебных команд, таких как /start, /skip и др., ибо они только усложнят повествование. Расскажу же я о компоненте, генерирующей следующий вопрос для пользователя.


Этап 1: Здравствуй, мир!


На этом этапе наше внимание было сосредоточено на других вещах, поэтому для генерации вопросов было написано самое простое, что могло бы правдоподобно работать. Вот что получилось:


  • Вопросы хранятся в массиве. Массив заполняется в static-блоке.
  • Номер текущего вопроса хранится в табличке.
  • При обращении в табличке инкрементируется номер текущего вопроса и возвращается элемент массива с таким индексом


Практически сразу стало понятно, что хардкод списка вопросов — это не очень хорошо и надо бы этот список загружать хотя бы из файла. При этом в сущности "вопрос” уже заключались сам текст вопроса и варианты ответов на него и было достаточно очевидно, что структура сущности этой будет расширяться и вширь, и вглубь. Для целей записи этого дела в файл идеально подходит решили использовать формат XML. В таком виде через месяц после начала работ эта компонента успешно прошла первое демо.
Итого по первой версии:


  • Плюсы:
    • Очень легко и быстро в реализации.
  • Минусы:
    • Если ничего дополнительного не делать с индексом, то получается слишком мало функционала (нет поддержки ветвлений и циклов).
    • Если дополнительно колдовать над индексом, получается не совсем секьюрно. Ошибки и неточности таких манипуляций приводили к очень странному поведению бота.

Этап 2: Ветвления, последовательный доступ


Посмотрев на решение из предыдущего пункта и подумав над его недостатками, было решено двигаться в сторону блок-схемной модели разговора. Эта модель была немного сложнее для понимания, но, во-первых, позволяла реализовать алгоритм разговора любой сложности, а во-вторых, из нее наружу торчал только один public-метод — получение следующего вопроса.
Изначально было реализовано два основных блока: следование и ветвление:



Но уже было понимание того, что блоки можно объединять в конструкции посложнее. Такие конструкции могут реализовывать тот же интерфейс (в котором один метод — получение следующего вопроса) и при этом объединять в себе несколько элементарных блоков. Примеры таких конструкций — условие с тремя исходами и цикл:



В нашем боте не было особо большого зоопарка таких конструкций, но возможность этого при написании кода учитывалась.


А вот такая получилась общая схема работы компоненты, показанной на втором демо:


  • Каждый вопрос является частью ноды. Интерфейс ноды состоит из одного метода — getNext();
  • В контексте пользователя хранится ссылка на его текущую ноду;
  • При обращении у текущей ноды вызывается getNext(), результат возвращается и сохраняется в контексте пользователя


Бот с таким вопросником был показан на втором демо. Итого по второй версии:


  • Плюсы:
    • Поддерживает ветвления, циклы и много чего другого.
    • Хорошо инкапсулирована.
  • Минусы:
    • Контекст пользователя не сериализуется. При сбое приложения контекст пользователей можно восстановить только по косвенным признакам, что сложно и не всегда возможно.
    • В коде парсера XML начинают открываться врата ада.

Этап 3: Нужно больше XML


Когда был написан парсинг XML для трех блоков (следование, ветвление и цикл), стало понятно, что с парсером надо что-то делать. Код превращался в спагетти, и добавление нового блока делалось очень трудоемко. Первый попавшийся вариант, предложенный гуглом, — jaxb при беглом осмотре с трудом натягивался на задачу. А задача была такой: распарсить список нод, где каждая нода представлена своим классом (указанным в атрибуте) и содержала бы заранее неизвестный список полей. Тип полей при этом также мог быть интерфейсным, в таком случае точный класс поля также указывался в XML-файле. Было решено писать свой парсер с блекджеком и reflection-ом. Ядро получившегося парсера выглядело примерно так:


Object getInstance(XMLTag xmlTag) {
    if (xmlTag.getName() in simpleClassInstantiators.keySet()) {
        return simpleClassInstantiators.get(xmlTag.getName())
            .instantiate();
    }
    String fullClassName = classpaths.get(xmlTag.getName()) + xmlTag.getAttr(“class”);
    Object result = InstantiateWithReflection(fullClassName);
    for (XMLTag child : xmlTag.getChildren()) {
        Object childObject = getInstance(child);
        setFieldWithReflection(result, child.getAttr(“fieldName”), childObject);
    }
    return result;
}

List<Node> getNodeList(XMLTag xmlRootTag) {
    return xmlRootTag.getChildren().stream()
        .map(x -> getInstance(x))
        .map(x -> (Node)x)
        .collect(Collectors.toList());
}

Такой XML-парсер работал на следующих соглашениях:


  • Каждый XML-тег должен быть или в списке “простых” классов, или в списке “сложных”. Если тега в этих списках нет, то вызывается ошибка.
  • Для каждого имени тега из “простого” списка должен быть написан инстанциатор.
  • Для каждого имени тега из “сложного” списка в структуре классов должен быть пакет, содержащий классы, реализующие один интерфейс. При этом имя одного из этих классов должно совпадать с атрибутом class.
  • [не обязательно] Для каждого класса, создаваемого “сложным” образом, можно было указать список обязательных полей.

Оба списка инициализировались в static-блоке. В результате добавление новых типов нод или изменение структуры существующих происходило по следующему алгоритму:


  • Внести изменения в основной код (без учета чтения из XML).
  • Внести изменения в исходный XML-файл.
  • Если добавляются новый интерфейс и классы, имплементирующие его, используются в полях новых (или измененных) нод, то добавить запись в “сложный” список.
  • Если добавляется новый относительно примитивный тип, то написать для него инстанциатор и добавить его в “простой” список.

При этом последние два пункта были протестированы, но при написании бота не использовались. Список интерфейсов “сложных” типов не менялся, а инстанциаторы для примитивных типов были написаны сразу — и их нам хватило. Т.е. мы просто меняли структуру нужных классов и XML-файл, что было существенным улучшением по сравнению с этапом 2.


Сложности чтения из XML были не единственной проблемой, оставшейся со второго этапа. Когда мы со скрипом прикрутили к нашему боту БД, мы обнаружили, что не можем хранить ссылку на текущий узел для каждого пользователя. Хотя бы потому, что это бы не позволило боту восстановить текущее состояние пользователя после рестарта. Переделывать текущую структуру компоненты мы не стали, просто обернули ее в класс, который умел правильно работать с id ноды. Никаких кардинальных изменений структуры для этого не потребовалось.
В итоге на третьем — финальном — демо структура моей компоненты была примерно такой:



Итого по третьей версии:


  • Плюсы:
    • Поддерживает алгоритм разговора любой сложности.
    • Контекст пользователя сериализуется => появляется устойчивость к сбоям.
    • Добавление новых типов нод и изменение структуры существующих. делается легко и не требует копания в кишках XML-парсера.
  • Минусы
    • XML-парсер требует исполнения соглашений, связывающих код со структурой классов.

Этап 4. Размышления о вечности, многопоточности, масштабируемости и т. д.


Если бы у рыб была шерсть Если бы наш бот пошел в продакшн, то рано или поздно мы бы столкнулись с некоторыми дополнительными проблемами, которые были оставлены за бортом во время разработки. Тем не менее говорилось о них довольно много, поэтому и здесь я о них напишу.


Во-первых, не раз и не два поднимался вопрос о том, что делать, если список вопросов изменится. В итоге мы решили, что это проблемы кота писать механизм, позволяющий на лету безопасно менять опросник произвольным образом, незачем и неизвестно как. При этом возможность перечитать вопросник из XML все-таки была на случай незначительных изменений, но особо протестирована такая возможность не была. Изменения при этом применялись сразу для всех пользователей.
Во-вторых, одного потока было бы явно недостаточно. Для решения потенциальных проблем, связанных с многопоточностью, были предложены разные схемы:


  • Синхронизированные методы в вопроснике.
  • Копирование вопросника для каждого пользователя при его регистрации.
  • Требование thread-safe-нод.
  • Для каждого пользователя при запросе ноды синхронизированно копировать ее с эталона (или даже пула эталонов).
  • и т.д,

но выбрать (и тем более реализовать) одну из этих схем мы просто не успели.


В-третьих, уже на последнем демо поднимался вопрос о горизонтальном масштабировании. Тут наш бот нас не подвел: все его узлы (включая вопросник) были составлены так, что допускали не только горизонтальное масштабирование из коробки, но и (при необходимости) разбиение на микросервисы.


Что получилось в итоге?


В итоге получилась начинка для простенького чат-бота. Простенького потому, что опрос для резюме сам по себе не является сложной задачей. Но даже в таком виде эта компонента обладает большим ресурсом по расширяемости. Он позволяет описывать разговор с пользователем практически любой сложности. Благодаря блок-схемному дизайну он позволяет относительно легко заменить редактирование XML-файла визуальным редактором (на самом деле это было в наших планах, но мы снова не успели). Благодаря легкорасширяемой структуре XML можно быстро добавлять новые фичи (например, использование нескольких вариантов текста вопроса). Также бот получал из коробки горизонтальную масштабируемость и требовал небольших усилий для добавления многопоточной работы. Компоненты бота были очень хорошо изолированы и допускали разбиение на микросервисы. Из минусов самым серьезным была невозможность менять вопросник на лету. Добавление такой возможности было бы очень трудоемко и потребовало бы серьезного пересмотра архитектуры.

08/15/2017 10:44:47
0

комментарии (0)