Skip to content

Declarative state-machine framework for chat-bots

License

Notifications You must be signed in to change notification settings

VoidDruid/Spockesman

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

86 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spockesman

Declarative state-machine - mainly for chat bots

Фреймворк представляет собой реализацию паттерна state machine, предназначенную для описания логики взаимодействия юзера с системой в виде графа состояний.

Примеры использования в папке examples. Примеры довольно базовые и будут дописываться. В частности examples/main.py - описание handler-ов и состояний, examples/config.py - пример конфига, examples/message.py - пример объявления типа результата.


Основными понятиями являются: контекст Context, состояние State, обработчик Handler, команда Command.

Принцип использования

Для запуска фреймворка необходим сделать две вещи - вызвать setup(...), и определить, в каком месте будет вызываться process(...).

Возьмем для примера абстрактного чат-бота:

from spockesman import process, setup, InvalidCommandException  # импортируем то что нам надо

import config as spockesman_config  # импортируем модуль с конфигурацией, его надо написать самому

setup(spockesman_config)  # передаем конфигурацию в setup, это создаст необходимое окружение


class Bot(SomeChatBot):
    # у нашего теоретического бота, on_message вызывается при поступлении сообщения от юзера
    # chat_id - идентификатор юзреа
    # chat_message - его сообщение
    # additional_data - какие-то дополнительные данные
    def on_message(self, chat_id, chat_message, additional_data):
        """
        При получении собщения, вызываем process, с аргументами - id usera, его ввод, и любые дополнительные данные
        Фреймворк сам загрузит контекст юзера, или создаст новый, определит состояние пользователя,
        вызовет правильный обработчик и сохранит контекст обратно в бд.
        
        По скольку программист сам описывает что будет возвращаться при обработке сообщений,
        мы рассчитываем что из process мы получили ответное сообщение и посылаем его.
        Если же поймали InvalidCommandException, считаем что юзер отправил "мусор" и игнорируем.
        """
        try:
            result = process(chat_id, chat_message, additional_data)
        except InvalidCommandException:
            return
        self.send_messages(chat_id, result)

Данный пример показывает самый простой способ использования фреймворка, и его возможности этим далеко не ограничиваются. Объявление handler-ов, создание конфигурации, состояний и т.д. будет описано далее.

Command и handler, команды и обработчики


Command

Комманды - объекты, представляющие собой намерения пользователя. Состоят из название, совпадающего с названием объекта - Command.Start - и триггеров, то есть разных вводов юзера, которые относятся к данной команде - для комманды start это может быть ['/start', 'start']. Можно использовать любоей объект, поддерживающий сравнение, не только строки.

Иногда надо получить доступ к триггерам команды, например чтоб в боте создать кнопку для пользователя. Для этого можно воспользоваться специальным полем .triggers, которое возвращает объект со следующими методами:

  • Command.Start.triggers.any - любой триггер (случайный выбор)
  • Command.Start.triggers.all - все триггеры (список)
  • Command.Start.triggers.first - первый триггер
  • Command.Start.triggers.last - последний триггер

Объект Commands заполняется коммандами из конфига

В общем случае, для комманд он выглядит следующим образом:

COMMANDS = {
    'Echo': None,
    'End': '/end',
    'Start': ['/start', 'start'],
}

Ключи - названия комманд, значения - триггеры. None можно использовать для "сервисных" комманд для особых состояний, про это будет сказано далее.

Handler

Для комманд необходимо регистрировать обработчики - @handler. Это делается с помощью декоратора handler.

Пример:

from spockesman import Command, handler, ABCResult


class Message(ABCResult):
    def __init__(self, text):
        self.text = text


@handler(Command.Start)
def start_handler(context, user_input, *args, **kwargs):
    return Message('Привет!')

Разберем данный пример. Из обработчиков можно возвращать только объекты, явялющиеся подклассами ABCResult, состояние или его название в виде строки, либо списки из вышеперечисленного. Тут мы объявили класс Message как результат, который мы можем вернуть из handler.

Затем мы использовали декоратор @handler(Command.Start), чтобы указать фреймворку, что обработчиком для комманды Start будет функция start_handler (название не важно).

Все обработчики должны придерживаться одного интерфейса -

(контекста, ввод юзера, доп. *args, доп. *kwargs) -> ABCResult, str, State, либо любой iterable из этих элементов

Если вернуть из обработчика генератор, то он будет приведен к tuple во время обработки, поэтому не стоит ожидать генератор на "принимающей стороне".

Если вернуть из обработчика состояние или его название, или если оно будет встречено в списке результатов, пользователь будет переведен в это состояние, и из него будет взят default (см. секцию про состояния)

Context, контекст

Контекст - объект с информацией о пользователе, которая нужна системе, и позволяющий хранить дополнительную информацию, которая нужна разработчику. Простейший контекст состоит из user_id - идентификатор юзера и state - текущее состояние юзера. В контексте можно сохранять дополнительные данные (только примитивы):

from spockesman import Context
context = Context('user_id')
context.store('key', 'value')
context['key'] == 'value'  # True
context.pop('key')  == 'value' # removes 'key', returns 'value'

Для хранения контекстов используется единый механизм, встроенный в фреймворк - программист может выбрать между redis и sqlite (или написать свой бэкенд для хранения, для примера можно взять spockesman.context.backend.redis_backend), и описать его конфигурацию в питоновском модуле (примеры config.py есть в tests и в example). Разработчику не надо самому заботиться о хранении контекста.

В общем виде, конфигурация выглядит так:

# config.py

CONTEXT_BACKEND = {
    'Name': 'example',
    'Type': 'sqlite_backend',
}

При такой конфигурации, для хранения контекстов будет использоваться sqlite база с именем example. При использовании redis параметров для настройки больше, их можно найти в tests/config.py

Наследование от контекста и кастомные поля

Интерфейсы, описываемые в этой секции, могут меняться и находятся в разработке


От Context можно наследоваться, если, например, хочется иметь дополнительные методы или поля. Данные, сохраненные с помощью .store хранятся во внутреннем словаре, и будут сохранены в бд в любом случае, поля же, по умолчанию, сохраняются только публичные.

Плюс использования подклассов и новых полей в том, что для хранения полей используется pickle, а для того что сохранено через .store - json, поэтому в дополнительных полях контекста можно хранить любые данные, а не только примитивы. Однако pickle не безопасен, поэтому контроль за безопасностью хранимых и загружаемых данных возлагается на разработчика - для этого и существует разделение способов хранения.

Пример:

from spockesman import Context
class CustomContext(Context):
    def __init__(self, new_field, *args, **kwargs, ):
        super().__init__(*args, **kwargs)
        self.new_field = new_field

Поле new_field "переживет" сохранение/загрузку контекста. Если вы хотите хранить дополнительные поля, или выполнять с ними какие либо действия перед загрузкой/сохранением, вы можете переопределить методы prepare_additional_fields и install_additional_fields - первый вызывается перед сохранением для получения объекта для хранения в базе, второй - для загрузки данных, он получает как аргумент объект, возвращенный первым методом.

То есть - context.prepare_additional_fields() -> return object -> pass to pickle -> save to database -> load and unpickle -> pass object to context -> context.install_additional_fields(object). По умолчанию, prepare_additional_fields возвращает словарь со значениями публичных полей класса, а install_additional_fields добавляет их к новому инстансу.

States, состояния

Состояния - подклассы базового класса State. Они представляют собой узлы в графе состояний. В них содержится логика работы данного конкретного состояния.

Например, тип состояния CyclicState будет передавать ввод юзера в один и тот же handler, если не найдет handler-ов с соответстующими триггерами. В какой именно handler будет передаваться ввод указывается в поле класса cycle.

Определить самое просто состояние можно унаследовавшись от State и указав нужные значения полей класса:

from spockesman import State


class MainState(State):
    # default, который будет возвращен юзеру, когда он приходит в состояни "без инпута"
    # т.е. если пользователь новый, либо его "пропушили" в состояние из handler-а (см. выше)
    default = Message('Добро пожаловать!')
    
    # Commands - словарь, ставящий в соответствие коммандам от пользователя состояние,
    # в которое он должен будет перейти
    commands = {
        Commands.Start: AnotherState,
    }

Самая интересная часть тут это commands = {...} - таким образом мы описываем граф состояний, где комманды и их обработчики это ребра графа, а состояния - вершины. Комманды сами по себе не являются ребрами, они нужны лишь для привязки функции-обработчиков. Полное описание ребра состоит из исходной вершины (класса в котором мы описываем commands, тут это MainState), комманды и приязанного к ней обработчика (тут это Command.Start) и конечной вершины (тут - AnotherState) Таким образом сейчас у нас получается вершина MainState, с 1 ребром, связывающим ее с AnotherState.

Конкретные состояния для юзера создаются путем создания инстанса состояния с контекстом пользователя -

from spockesman import Context, State
user_context: Context  # созданный ранее контекст пользователя
user_state = State(user_context)

Теоретически, "вводом пользователя" и триггером для handler-а может быть почти любой объект, но пока что остановимся на тексте. Для обработки ввода пользователя, инстанс состояния надо вызвать -

result = user_state(user_input, *additional_args, **additional_kwargs)

result будет каким-либо подклассом ABCResult или списком из них.

Обработка проходит следующим образом:

  1. State среди всех комманд ищет комманду, среди триггеров которой был бы данный user_input. Если не находит, выбрасывается InvalidCommandException. Само по себе это может не быть "ошибкой в программе", часты use case является необходимость игнорировать "мусор" от пользователя, тогда можно просто ловить InvalidCommandException.
  2. Если комманда найдена, то проверяется, является ли комманда глобальной (доступной из любого состояния). Если да, то вызывается соответствующий обработчик и возвращается результат.
  3. Если комманда не глобальная, то проверяем, доступа ли эта комманда в данном состоянии. Если нет, выбрасывается InvalidCommandException
  4. Если комманда найдена, ищем зарегистрированный для нее handler. Если его нет, выбрасывается NoHandlerException.
  5. Если комманда найдена, доступна в текущем состоянии, и существует handler для нее, то вызывается handler c аргументами user_input, context, *args, **kwargs, пользователь переходит в состояние, привязанное к обрабатываемой команде в текущем состоянии (т.е. происходит переход по графу состояний), и возвращается результат работы handler

По умолчанию доступны следующие базовые классы состояний:

  • State, самое простое состояние, принцип его работы описан выше
  • CyclicState, уже упомянутый выше - будет передавать ввод юзера в один и тот же handler, если не найдет handler-ов с соответстующими триггерами. В какой именно handler будет передаваться ввод указывается в поле класса cycle. То есть, используя описание работы описанное выше, можно сказать что в пункте 3 вместа выброса InvalidCommandException, будет использована комманда, указанная в state.cycle, и юзер останется в текущем состоянии
  • TransientState - аналогично CyclicState, но имеет поле transition - словарь с двумя полями: Command - комманда по умолчанию аналогично cycle из CyclicState, и State - состояние в которое перейдет пользователь

Кроме того, для корректной работы, необходимо объявить исходное состояние для пользоателя. Это можно сделать с помощью декоратора @initial:

from spockesman import State, initial


@initial
class SomeState(State):
    ...

Roadmap:

  • More tests: context backend, state processing, tricky handlers
  • Remove global variables! Make state machine a class, instead of collection of COMMANDS and STATES
  • Ideas:

    • Generate config file from loaded state machine
    • Move to pytest-style tests?