QuBot: 2. Слоты и действия


Введение

В QuBot все данные хранятся в переменных, называемых слотами. Они являются парами ключ-значение. Ключ - это имя слота. Значение слота может иметь различные типы:

Пример использования строкового слота (str) был рассмотрен в предыдущем документе, где в слоте NAME сохранялось имя клиента, полученное из строки INPUT_VALUE. Затем этот слот использовался в приветственном тексте. Рассмотрим теперь более сложный случай работы со списком.


Выбор товара

Пусть есть набор товаров, которые может заказать пользователь бота. Название выбранного товара будем сохранять в слоте ITEM (строка), а его количество в слоте AMOUNT (целое число). Список с заказанными товарами будем хранить в слоте ORDER. Кроме этого, нам понадобятся вещественные слоты PRICE (цена товара) и TOTAL_SUM (суммарная стоимость заказа). Объявим эти слоты в разделе slots:

slots:
    ITEM:                                   # название товара
        type: str                           # это строка
    AMOUNT:                                 # количество товара
        type: int                           # целое число (штуки)
    PRICE:                                  # цена товара
        type: float                         # вещественное число        
    TOTAL_SUM:                              # итого (сумма заказа)
        type: float                         # вещественное число
        value: 0.0                          # начальное значение     
        
    ORDER:                                  # список покупок
        type: list                          # список [ { _ITEM, _AMOUNT, _PRICE } ]

    ERROR:                                  # сообщения об ошибках
        type:   categorical                 # категориальный слот          
        value:  NO_ERROR                    # значение по умолчанию
        values:                             # может принимать следующие значения:
            NO_ERROR:      ""
            WRONG_NUMBER:  "Число должно быть целым!"
Последний, категориальный слот ERROR понадобится для вывода информации об ошибочно введенном количестве товара. Реализация текстов ошибок в форме категориального слота, позволит в дальнейшем легко перейти на мультиязычный интерфейс. Категориальный слот принимает одно из предопределённых значений, которые перечисляются в разделе values. В данном случае слот ERROR может принимать два значения: NO_ERROR и WRONG_NUMBER. Начальное значение слота (value) - это отсутствие ошибок NO_ERROR. С каждым значением можно связать строку, в том числе на разных языках.

Для простоты, перечислим все товары на начальном экране (в реальном боте необходимо использовать другие способы навигации по товарам, которые мы рассмотрим позднее):

states:    
    START_STATE:                   
      - goto: SCR_ITEMS_LIST      
    
    SCR_ITEMS_LIST:         
    
      - text: "Вы можете выбрать следующие товары:"
     
      - row:
          - button: "🥛 Молоко"
            slots:  { ITEM: Молоко,  PRICE: 30 }
            
          - button: "🍷 Вино"
            slots:  { ITEM: Вино,    PRICE: 500 }
            
      - row:
          - button: "🧀 Сыр"
            slots:  { ITEM: Сыр,     PRICE: 100 }
            
          - button: "🍬 Конфета"
            slots:  { ITEM: Конфета, PRICE: 10 }
                
      
      - state: SCR_AMOUNT  # общий переход из кнопок
После ключа slots мы указываем в фигурных скобках слоты и значения, которые эти слоты получат после нажатия данной кнопки. Это "сокращённый" синтаксис. Два следующих примера эквивалентны:
slots: { ITEM: Молоко, PRICE: 30 }                          slots: 
                                                                ITEM:  Молоко
                                                                PRICE: 30
Так как из всех кнопок нужно будет переходить в одно и тоже состояние SCR_AMOUNT, это указывается один раз в последней строке состояния.

В состоянии SCR_AMOUNT сделаем три кнопки с количествами, а также разрешим вводить произвольное количество в строке ввода:

#...    
    SCR_AMOUNT:
      - text: | 
            $ITEM - отличный выбор! 
            Сколько Вы хотите заказать единиц?
 
      - row:
          - button: "1"               
            slots:  { AMOUNT: 1 }     
          - button: "2"               
            slots:  { AMOUNT: 2 }     
          - button: "3"               
            slots:  { AMOUNT: 3 }    
            
      - button: "Назад"
        state: PREV_STATE
.
      - text: "Можно ввести любое количество. $ERROR"
      - slots: { ERROR: NO_ERROR }
      
      - input:                      
          - extract: [ AMOUNT ]                         
          - if: $AMOUNT == None                   # ошибка извлечения данных
            then:           
               - slots: { ERROR: WRONG_NUMBER }        
               - state: SCR_AMOUNT                # повторим ввод количества
            
      - state: SCR_ADD_ITEM_TO_ORDER   # по умолчанию добавляем в заказ

Первое поле text содержит многострочный текст. Для его ввода, после text: , необходимо поставить вертикальную черту и далее с новой строки, с отступом вводить несколько строк текста. В боте они также будут в виде двух строчек. Напомним, что имя слота перед которым стоит знак доллара внутри текста: "$ITEM" заменится на его значение, которое было получено при нажатии кнопки в предыдущем состоянии.

Обратим внимание на значение слота $ERROR в тексте перед строкой ввода. Если ошибки нет, там ничего не будет. При возникновении ошибки мы установим ERROR в значение WRONG_NUMBER и в этом месте появится дополнительный текст: "Число должно быть целым!".

В элементе списка input в поле extract указывается, какой слот необходимо извлечь из текста. Так как слот AMOUNT имеет тип int, будет извлечено первое целое число. Если в строке не было числа, слот AMOUNT будет иметь значение None, что проверяется в разделе if. Если число не было извлечено, установится значение слота ERROR и произойдёт повторное предложение ввести количество (снова запустится состояние SCR_AMOUNT). В противном случае бот отправляется в состояние добавления товара SCR_ADD_ITEM_TO_ORDER, как и из кнопок 1,2,3 (последняя строка - состояние по умолчанию).


Добавление в список

Теперь сделаем состояние SCR_ADD_ITEM_TO_ORDER, где в список ORDER добавляется выбранный товар (ITEM), его количество (AMOUNT) и цена (PRICE):

   
#...    
    SCR_ADD_ITEM_TO_ORDER:              
      - action: ACTION_LIST_ADD_ITEM
        list:   ORDER
        item:  { _ITEM: $ITEM, _AMOUNT: $AMOUNT, _PRICE: $PRICE }       
        equal: { _ITEM   }
        total: { _AMOUNT }
       
      - action: ACTION_LIST_TOTAL
        list:   ORDER                    
        total: { TOTAL_SUM: $_AMOUNT * $_PRICE }     
             
      - text: |
            Я добавил $AMOUNT ед. $ITEM в заказ.
            Всего Вы заказали на $TOTAL_SUM.

      - button: "🧾 Добавим ещё?"           

      - button: "🛒 Заказ"           
        state: SCR_SHOW_ORDER                   
        
      - button: "Очистить"           
        actions:
          - action: ACTION_LIST_CLEAR 
            list:   ORDER
            
      - button: "Удалить товар?"           
        state: SCR_REMOVE_FROM_LIST

      - state: SCR_ITEMS_LIST            

Кроме стандартных интерфейсных настроек (text, buttons), в начале состояния запускаются последовательно два действия action. Первое действие ACTION_LIST_ADD_ITEM добавляет в список ORDER (ключ list) один элемент (ключ item). Этот элемент является объектом, состоящим из ключей и значений. Ключи могут иметь любые имена (совпадающие или нет с именами слотов). Тем не менее, чтобы не путаться, стоит делать имена ключей отличные от имён слотов, например, начинать их с подчёркивания. Тогда сразу будет ясно что это - ключ или глобальный слот. Значения ключей элемента списка - это строки, числа или значения слотов. Выше этот элемент состоит из названия товара, выбранного количества и цены. Напомним, что доллар перед именем слота означает получение его значения.

Если мы добавим две бутылки вина, а затем одну конфету, то список ORDER будет иметь вид:

   
[ 
    { _ITEM: Вино,    _AMOUNT: 2, _PRICE: 500 },
    { _ITEM: Конфета, _AMOUNT: 1, _PRICE:  10 },
]
Квадратные скобки - это список, элементы которого объекты (фигурные скобки), перечисляются через запятую.

Ещё два параметра в ACTION_LIST_ADD_ITEM (если они есть) управляют процессом агрегирования. При повторном добавлении того же товара, должен появиться не новый элемент списка, а обновиться (увеличится) количество этого товара. Поле total содержит ключи элементов списка которые необходимо агрегировать (объединив значения _AMOUNT), при совпадении ключей, указанных в поле equal. Если повторно добавить три конфеты, то элементов по-прежнему будет два, с _AMOUNT: 4 в последнем элементе.

Второе действие ACTION_LIST_TOTAL в списке ORDER (ключ list) суммирует по всем элементам списка значение произведения его ключей $_AMOUNT * $_PRICE, что даёт суммарную стоимость заказа. Результат помещается в слот TOTAL_SUM, что описывается в аргументе действия total.


Вывод списка

Ещё одно встроенное действие ACTION_LIST_SHOW выводит содержимое списка, чтобы получить что-то типа:

Вы заказали:

1. Вино: 3*500 = 1500 
2. Сыр:  2*100 = 200 

Итого: 1700.0 

Что-нибудь ещё?

Аргумент list действия ACTION_LIST_SHOW содержит имя слота (ORDER) со списком. Затем идёт раздел результатов работы действия result.
В нём перечисляется четыре поля, каждое из которых может включать список из любых объектов состояния. Поле empty содержит текст, который будет выведен, если список ещё пустой. Если список не пуст, сначала выводится (если есть) поле head, затем построчно выводятся элементы списка в формате, задаваемом полем item.

#...
    SCR_SHOW_ORDER:
      - action: ACTION_LIST_SHOW
        list:   ORDER
        result:
            empty:  
              - text: "Вы пока ничего не заказали"
            head:   
              - text: "Вы заказали:"
            item:   
              - text: "$LIST_ITEM_INDEX. $_ITEM: $_AMOUNT*$_PRICE = {$_AMOUNT*$_PRICE}\n"
                p: false
            tail:   
              - text: "Итого: $TOTAL_SUM"                
 
      - text: Что - нибудь ещё?
         

      - button: "✔️ Да"
        state:  SCR_ITEMS_LIST
                 
      - button: "🕿 Нет"
        state:  SCR_ITEMS_LIST      # нужно отправлять к оператору, если ORDER не пуст.
        
      - button: "❌ Удалить товар?"           
        state: SCR_REMOVE_FROM_LIST

Обратим внимание на вычисление {$_AMOUNT*$_PRICE}, дающее стоимость данного товара с учётом количества. Эта формула внутри произвольного текста должна окружаться фигурными скобками. Внутри неё стоят слоты (знак доллара). Их имена сначала ищутся среди ключей элемента списка. Если их там нет, то имя ищется в списке слотов. Соответствующее значение подставляется на место переменной и производится вычисление.

По умолчанию, в web-chat каждый текст выводится с нового абзаца (окруженный html-тегами <p> ... </p>). Если в тексте есть обрывы строк, которые заданны явно ("\n") или текст многострочный (вертикальная черта), то вместо них в html будет тег <br> (переход на новую строку без отступа). Чтобы элементы списка были прижаты друг к другу, в тексте text из раздела item указывается свойство p: false, которое запрещает абзацы для текста, а символ обрыва строки ("\n") добавляется в конец строки. В результате, строки элементов списка будут прижаты друг к другу и отделены абзацами от заголовка (head) и окончания (tail).

Встроенный слот LIST_ITEM_INDEX является номером элемента списка (начиная с единицы).


Категории для языков

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

slots:
    ITEM:                                   # название товара
        type: categorical                   # одно из предопределённых строк
        values:
            Молоко: 
                en: "Milk"                  # английский
                ru: "Молоко"                # русский
            Вино:
                en: "Wine"         
                ru: "Вино"         
            Конфета: "Конфета"              # в любом языке
            Сыр:     "Сыр"                  # (потом переведём)

Теперь пример выше будет работать при переустановке языка. При выводе списка, естественно, все текстовые поля из разделов empty, head, item, tail также могут быть мультиязычными. Реализацию для двух языков можно найти в проекте http://127.0.0.1:5000/bot?bot=example_list_en_ru.


Удаление элементов

Для удаления элементов из списка служат два действия: ACTION_LIST_CLEAR и ACTION_LIST_REMOVE_ITEM. Первое действие полностью очищает список, удаляя все элементы:

#...
    SCR_CLEAR_LIST:      
      - action: ACTION_LIST_CLEAR                # очищаем весь список
        list:   ORDER                            # из слота ORDER
Действие ACTION_LIST_REMOVE_ITEM может работать в двух режимах. В первом - просто удаляется элемент с заданным номером index (начиная с единицы):
#...
    SCR_REMOVE_FROM_LIST1:
      - action: ACTION_LIST_REMOVE_ITEM
        list:   ORDER
        index:  2                                # удалить второй элемент

Обычно, вместо явного указания номера, используется значение слота (например, index: $INDEX), вводимого из строки ввода, или при помощи кнопок.

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

#...
    SCR_REMOVE_FROM_LIST1:
      - action: ACTION_LIST_REMOVE_ITEM
        list:   ORDER
        equal: { _ITEM: "Вино" }                 # во всех элементах, где есть "Вино"
        dec:   { _AMOUNT: 2  }                   # уменьшить _AMOUNT на два

Выше, в элементе списка, в котором есть ключ _ITEM со значением "Вино" уменьшается на 2 для значения ключа _AMOUNT. Если в результате уменьшения это значение стало равным или меньшим нуля - элемент списка удаляется. Естественно, значения ключей в поле dec должны быть целыми или вещественными числами.


Пустой список или нет

Предположим, нам необходимо показать кнопку "Оформить заказ?" только в том случае, если список ORDER с заказами не пуст (что-то в него уже добавили). Это можно сделать следующим образом:

#...
    SOMESING_ELSE:
      - text: "Хотите что-то добавить в Ваш заказ?"
        
      - button: "Да!"
        state:  SCR_ITEMS_LIST
      
      - action: ACTION_LIST_EMPTY
        list:   ORDER
        result:
            false:                           # показываем кнопку
              - button: "Оформить заказ?"
                state:  CALL_TO_OPERATOR

В этом примере вызывается действие ACTION_LIST_EMPTY проверки является ли список ORDER (list) пустым. Это действие возвращает (result) два значения (true и false). Если список не пуст (false), мы выводим кнопку (button). Здесь же можно вывести текст и другие интерфейсные элементы, а также вызвать новые действия.

Полный проект с рассмотренными примерами по работе со списком товаров можно запустить в: http://127.0.0.1:5000/bot?bot=example_list_en_ru из архива bbot.zip. Более подробная информация о доступных действиях содержится в "Справочнике действий".


Листалка списков

Обычно товаров бывает много, их сопровождает название, картинка и описание. Рассмотрим как реализуется "листалка" списка с подобной информацией. Создадим сначала четыре слота:

slots:           
    MOVIE_STARS:           # список актрис
        type: list
        value:
          - { _NAME: "Орнелла Мути", _WANT: "●", _IMAGE: im/Muti.jpg  }
          - { _NAME: "Николь Кидман",_WANT: "●", _IMAGE: im/Kidman.jpg}
          - { _NAME: "Шэрон Стоун",  _WANT: "●", _IMAGE: im/Stone.jpg }

    MOVIE_STARS_COUNT:     # длина списка
        type: int
        
    MOVIE_STARS_ID:        # номер элемента
        type: int
        value:  1
        
    WANT:                  # пригодится для чекбоксов
        type: str

Для наглядности, содержимое списка MOVIE_STARS описано непосредственно при объявлении слота. В реальном боте подобные списки часто большие и их стоит грузить из файла. Для этого служит действие ACTION_LIST_LOAD с аргументами list (имя слота типа list) и file (имя файла). Файл может иметь рассширение csv (электронная таблица) или yml (формат yaml). Такое действие с загрузкой списка обычно вызвается в START_STATE.

Мы же получим в стартовом состоянии только длину списка:

    START_STATE:    
      - action: ACTION_LIST_LENGTH      # получить длину списка
        list:   MOVIE_STARS             # из слота типа list
        slots:  { MOVIE_STARS_COUNT }   # в этом слоте будет число элементов MOVIE_STARS
        
      - goto: SCR_LIST_MOVIE_STARS      # переходим на экран листалки

Теперь реализуем собственно листалку. При нажатии на кнопки должен показываться тот же экран (с новым элементом). Поэтому в начале состояния опишем block с параметром clear: 0, что означает очистку контента состояния после выхода из него. Так как на кнопках объекта state не будет, при нажатии на них, снова выведется контент этого же состояния.

В кнопках будем уменьшать и увеличивать слот с номером элемента MOVIE_STARS_ID. Если он становится меньше единицы или больше MOVIE_STARS_COUNT, в условном объекте if сделаем "зацикливание" листалки (возврат на начало или конец списка). Для надёжности эти проверки стоят в начале состояния (вдруг где-то поменялся слот MOVIE_STARS_ID):

    SCR_LIST_MOVIE_STARS:              # экран с листалкой актрис

      - block:                         # после выхода из состояния
            clear: 0                   # очистить его содержимое на экране

      - if: $MOVIE_STARS_ID < 1        # номер элемента не меньше единицы
        then:
          - slots: { MOVIE_STARS_ID: $MOVIE_STARS_COUNT }
          
      - if: $MOVIE_STARS_ID > $MOVIE_STARS_COUNT # и не больше числа элементов
        then:
          - slots: { MOVIE_STARS_ID: 1 }

      - text: "С кем из них Вы знакомы?"            

      - action: ACTION_LIST_SHOW       # выводим список
        list:   MOVIE_STARS
        page:   $MOVIE_STARS_ID        # начиная с этой страницы
        count:  1                      # по одному элементу
        result:                        # как рисуем контент элемента
            item:
              - image:   $_IMAGE
                caption: "$_NAME"

      - row:                           # сторока из двух кнопок
          - button: "◁"               # предыдущий элемент
            actions:
              - slots: { MOVIE_STARS_ID: $MOVIE_STARS_ID - 1 }

          - button: "▷"                # следующий элемент
            actions:
              - slots: { MOVIE_STARS_ID: $MOVIE_STARS_ID + 1 }
Собственно вывод реализует действие ACTION_LIST_SHOW. Оно разбивает все элементы списка на "страницы" с числом count элементов на каждой странице (кроме возможно последней). Выше страница состоит из одного элемента, а в аргумент page передаётся номер элемента (попробуйте поменять count: 2).

Если список большой, его элементы стоит разбивать на группы (например, актрисы и актёры). Для этого в элементы списка добавляется ключ _KIND, а в ACTION_LIST_SHOW - аргумент equal: { _KIND: актрисы}. Тогда будут показываться только элементы соответствующие этому значению.


Чекбоксы

Реализуем теперь при помощи кнопок чекбоксы (при нажатии на кнопку будет меняться символ: выбрано ● или не выбрано ○). Для этого ранее в списке актрис был зарезервирован ключ _WANT, равный строке "●" или "○". Нам также понадобится строковый слот WANT. Кнопки для чекбоксов по-прежнему будут выводиться при помощи ACTION_LIST_SHOW. В заголовке кнопки button, перед именем актрисы _NAME поставим маркер _WANT:

Чтобы кружочки чекбоксов были друг под другом, у кнопки укажем свойство text_align: left (текст кнопки прижат влево) и отступ в пикселях от левого края text_margin: 75. Стоит иметь ввиду, что такое форматирование будет работать только для web-чата и проигнорируется месенджерами.

    SCR_CHECKBOX_MOVIE_STARS:          # экран с с чекбоксами

      - block:                         # после выхода из состояния
            clear: 0                   # очистить его содержимое на экране

      - text: "С кем из них Вы планируете встретиться?"            

      - action: ACTION_LIST_SHOW       # выводим список
        list:   MOVIE_STARS
        result:                        # как рисуем контент элемента
            item:                          
              - button: $_WANT $_NAME
                text_align: left       # надписи выравнены по левом краю
                text_margin: 75        # с отступом 75 пикселей
                actions:
                  - slots: { MOVIE_STARS_ID: $LIST_ITEM_INDEX }

                  - if: "$_WANT == '●'" # меняем в списке 
                    then: [ slots: { WANT: ○ } ]
                    else: [ slots: { WANT: ● } ]
                      
                  - action: ACTION_LIST_SET_ITEM
                    list:   MOVIE_STARS
                    index:  $MOVIE_STARS_ID                   
                    slots:  { _WANT:  $WANT }

Разберёмся теперь с разделом actions у кнопок. Он, как и все свойства кнопок, срабатывает только после нажатия кнопки. Тем не менее, значения всех локальных слотов (ключей данного элемента списка) запоминаются и при нажатии кнопки используются во всех объектах actions. Обратим внимание, что сохраняются именно значения и поменять их нельзя. Поэтому в if выясняется значение ключа _WANT у текущего элемента и устанавливается значение слота WANT (обратим внимание на одинарные кавычки '●', которые необходимы вокруг строк или строковых слотов под объектом if). Затем, при помощи ACTION_LIST_SET_ITEM, уже меняется значение ключа _WANT у элемента списка MOVIE_STARS под номером MOVIE_STARS_ID (его мы также запоминаем выше в slots).


Комбобоксы

Комбобокc разрешает пометить только один элемент в списке. Следующее состояние реализует это поведение:

#states:
    SCR_COMBO_MOVIE_STARS:
      - block:                         # после выхода из состояния
            clear: 0                   # очистить его содержимое на экране

      - action: ACTION_LIST_SET_ITEM   # все хотелки очищаем
        list:   MOVIE_STARS        
        slots:  { _WANT:  "○" }

      - action: ACTION_LIST_SET_ITEM   # ставим у активного элемента
        list:   MOVIE_STARS
        index:  $MOVIE_STARS_ID                   
        slots:  { _WANT:  "●" }

      - text: "Нет, можно выбрать только одну:"                            
      
      - action: ACTION_LIST_SHOW       # выводим список
        list:   MOVIE_STARS
        result:                        
            item:                          
              - button: $_WANT $_NAME
                text_align: left       
                text_margin: 75        
                actions:
                  - slots: { MOVIE_STARS_ID: $LIST_ITEM_INDEX }

                  - if: "$_WANT == '○'" # меняем в списке 
                    then: [ slots: { WANT: ● } ]                    
                      
                  - action: ACTION_LIST_SET_ITEM
                    list:   MOVIE_STARS
                    index:  $MOVIE_STARS_ID                   
                    slots:  { _WANT:  $WANT }

Все интерфейсные примеры собраны в проекте http://127.0.0.1:5000/bot?bot=example_interface.