Millet
Millet - библиотека для создания диалоговых агентов.
Вам может быть полезна библиотека, если
- Вы пишите асинхронных текстовых ботов, со сложной ветвистой логикой (например виртуального ассистента для поиска дешевых авиабилетов).
Millet позволит описывать логику таких ботов очень просто, как если бы это был синхронный диалог с пользователем на input-print.
Только сравните скилл на input-print:
def meeting_skill(message: str):
name = input("What is your name?")
print(f"Nice to meet you {name}!")
и этот же скилл в Millet:
from millet import BaseSkill
class MeetingSkill(BaseSkill):
def execute(self, initial_message: str, user_id: str):
name = self.ask(question='What is your name?')
self.say(f'Nice to meet you {name}!')
если бы вы описывали даже такой простой диалог для асинхронного бота своими силами, то вам бы пришлось использовать state-машину где-то хранить текущее состояние ожидания имени пользователя. С увеличением сложности диалога количество состояний будет только расти, как и головная боль в их поддержке.
- вы пишите бота, который работает с различными каналами связи (telegram, viber, vk, почта и тд.)
Концептуально логика формирования ответа пользователю не должна зависеть от инфраструктурных каналов передачи сообщений. Millet позволяет разделить эти слои и предоставляет готовые и понятные абстракции для описания ваших агентов.
Продал? Тогда погнали.
Установка
pip install Millet
Сходу простой пример
Возможно вы гений и поймете концепцию, просто посмотрев на пример.
Опишем агента, который умеет знакомиться:
from typing import Dict, List
from millet import Agent, BaseSkill, BaseSkillClassifier
class MeetingSkill(BaseSkill):
def execute(self, initial_message: str, user_id: str):
name = self.ask(question='What is your name?')
self.say(f'Nice to meet you {name}!')
class SkillClassifier(BaseSkillClassifier):
@property
def skills_map(self) -> Dict[str, BaseSkill]:
return {
'meeting': MeetingSkill(),
}
def classify(self, message: str, user_id: str) -> List[str]:
return ['meeting']
skill_classifier = SkillClassifier()
agent = Agent(skill_classifier=skill_classifier)
conversation = agent.conversation_with_user('100500')
использование:
>>> conversation.process_message('Hello')
['What is your name?']
>>> conversation.process_message('Bob')
['Nice to meet you Bob!']
всем остальным рекомендуется продолжить чтение документации.
Для начала познакомимся с основными понятиями:
Агент - это ваш виртуальный ассистент, в который пользователи могут слать запросы, а он будет формировать ответы.
Скилл - это умение вашего агента. Хотите научить его играть в города? Просто опишите скилл игры в города и подключите его к вашему агенту. Вот так вот просто.
Пользователь - какой-то человек, желающий воспользоваться скиллом вашего агента.
Диалог - это процесс использования пользователем скилла агента. Диалог подразумевает, что какой-то пользователь находится в каком-то состоянии использования скилла агента.
Классификатор скиллов - механизм определения каким скиллом хочет воспользоваться пользователь на основании его запроса. Библиотека дает возможность описать свой классификатор - это может быть сложная ML-модель либо старые добрые if-else.
Релевантность - иногда пользователь не хочет доводить до цели текущий скилл. Он может захотеть воспользоваться другим скиллом или просто закончить текущий. Релевантность определяет уместно ли текущее сообщение пользователя к текущему скиллу. Возможно это сообщение не очень уместно и стоит переключить скилл классификатором.
Описание скиллов
Для создания своего скилла необходимо реализовать класс BaseSkill.
from millet import BaseSkill
class MeetingSkill(BaseSkill):
def execute(self, initial_message: str, user_id: str):
name = self.ask(question='What is your name?')
self.say(f'Nice to meet you {name}!')
По умолчанию точкой входа является состояние initial_state_name='execute', но вы можете задать любое.
from millet import BaseSkill
class MeetingSkill(BaseSkill):
initial_state_name = 'meet'
def meet(self, initial_message: str, user_id: str):
name = self.ask(question='What is your name?')
self.say(f'Nice to meet you {name}!')
В рамках скилла доступна ссылка на user_id
.
Для того чтобы запросить какие-то уточнения от клиента в ходе выполнения скилла имеется ряд методов:
say
- просто вывести ответ пользователю, управление пользователю не передается
ask
- что-то спросить у пользователя, управление перейдет пользователю, когда от него будет получен ответ - скрипт продолжит свое выполнение с этого же места, метод вернет ответ пользователя
specify
- то же самое что и ask, но произойдет вызов классификатора (необходимо применять этот метод когда вы не уверены, что ответ релевантен для текущего скилла и есть шанс что это запрос на начала другого скилла)
direct_to
Иногда сложно уместить весь диалог в одно состояние или важно чтобы логика скрипта выполнялась ровно один раз (покупка чего-либо) В таких случаях в методах с передачей управления пользователю предусмотрен параметр direct_to, в который нужно передать следующее состояние диалога Состояние диалога - метод скила (по аналогии с методом execute). Можно передать сам метод или его название.
from millet import BaseSkill
class AgeSkillWithDirectTo(BaseSkill):
def execute(self, message: str, user_id: str):
age = self.ask('How old are you?')
self.wait_age(age)
def wait_age(self, age: str, user_id: str):
try:
age = int(age)
except ValueError:
self.specify(question='Send a number pls', direct_to='wait_age')
self.say(f'You are {age} years old')
Контекст скилла
Представляет собой горстку параметров в виде dict, которая доступна в рамках выполнения текущего скилла. Необходима для передачи некоторых параметров в соседнее состояние скилла.
from millet import BaseSkill
class SkillWithContext(BaseSkill):
def execute(self, message: str, user_id: str):
name = self.ask(question='What is your name?')
self.context['name'] = name
self.say(f'Nice to meet you {name}!')
age = self.ask(f'{name}, how old are you?')
self.wait_age(age)
def wait_age(self, age: str, user_id: str):
name = self.context['name']
try:
age = int(age)
except ValueError:
self.specify(question=f'{name}, send a number pls', direct_to='wait_age')
self.say(f'You are {age} years old')
Контекст агента
Сохранение контекста необходимо для хранения текущего состояния диалога с пользователем. Из коробки поставляются следующие менеджеры контексты:
- RAMContextManager - хранение диалога в оперативной памяти, очищается при удалении экземпляра менеджера из памяти
- RedisContextManager - персистентное хранение диалога в Redis, не сбрасывается между передеплоями
Вы можете определить свой механизм хранения контекста реализовав абстрактный класс BaseContextManager. Например если вам нужно хранить контекст в postgres.
Продвинутое использование
Для написания скилов полностью в синхронном стиле можно использовать определение side-функций. Библиотека сделает всю магию за вас.
from millet import BaseSkill
import random
class NumberSkill(BaseSkill):
side_functions = [
'random.randint',
]
def execute(self, message: str, user_id: str):
number_expected = random.randint(0, 100) # side function
number_actual = int(self.ask('Whats number?'))
if number_actual == number_expected:
self.say('ok')
else:
self.say('wrong')
В данном примере randint - side-функция, которая может вернуть разные значения при одинаковых входных данных. Для описания side-методов можно использовать side_methods.
from millet import BaseSkill
import random
class Rand:
def rand(self):
return random.randint(0, 100)
class NumberSkill(BaseSkill):
side_methods = [
(Rand, 'rand'),
]
def execute(self, message: str, user_id: str):
number_expected = Rand().rand() # side method
number_actual = int(self.ask('Whats number?'))
if number_actual == number_expected:
self.say('ok')
else:
self.say('wrong')
Если метод текущего скила, то просто напишите его имя.
from millet import BaseSkill
import random
class NumberSkill(BaseSkill):
side_methods = [
('NumberSkill', 'rand'),
]
def execute(self, message: str, user_id: str):
number_expected = self.rand() # side self-method
number_actual = int(self.ask('Whats number?'))
if number_actual == number_expected:
self.say('ok')
else:
self.say('wrong')
def rand(self):
return random.randint(0, 100)
Рекомендация: при сохранении в контекст, передаче сообщений и использовании side_functions/side_methods используйте простые структуры данных (str, int, bool, dict, ...). Это облегчит мигрирование кода скилов без возникновения проблем у активных диалогов. Также альтернативой может быть подход написания новых скилов, а не изменение существующих.
Actions
Необходимы для 100%-ой классификации скилов перед обработкой сообщения. Механика нужна для обработки действий пользователя, в которых мы уверены, наподобие нажатия кнопок.
# гарантированно произойдет классификации скилов
agent.process_action(
message='action',
user_id='100500',
)
# классификации скилов может произойти, а может и не произойти
# в зависимости от текущего скила
agent.process_message(
message='action',
user_id='100500',
)
Timeouts
Нужны для обработки ситуаций, когда клиент долго не отвечает. Обычно применяется для напоминания клиенту о необходимости ответить на вопрос.
from millet import BaseSkill
from millet.timeouts import (
MessageTimeOutException
)
class MeetingSkill(BaseSkill):
def execute(self, message: str, user_id: str):
try:
name = self.ask('What is your name?', timeout=10)
except MessageTimeOutException:
name = self.ask('I repeat the question: what is your name?')
return f'Nice to meet you {name}!'
Для поддержки данной функциональности, необходимо реализовать класс BaseTimeoutsBroker. Он должен инициировать создание ассинхронной задачи через timeout секунд.
Пример на celery:
from typing import Dict, List
from millet import Agent, BaseSkill, BaseSkillClassifier
from millet.timeouts import BaseTimeoutsBroker
class SkillClassifier(BaseSkillClassifier):
@property
def skills_map(self) -> Dict[str, BaseSkill]:
return {}
def classify(self, message: str, user_id: str) -> List[str]:
return []
skill_classifier = SkillClassifier()
# @celery.task
def agent_timeout_task(timeout_uid: str, user_id: str):
agent = Agent(skill_classifier=skill_classifier)
agent.process_timeout(timeout_uid=timeout_uid, user_id=user_id)
class CeleryTimeoutsBroker(BaseTimeoutsBroker):
def execute(self, user_id: str, timeout: int, timeout_uid: str):
agent_timeout_task.apply_async((timeout_uid, user_id), countdown=timeout)
celery_timeouts_broker = CeleryTimeoutsBroker()
agent = Agent(skill_classifier=skill_classifier, timeouts_broker=celery_timeouts_broker)
Примеры использования
https://github.com/odryfox/galangal - бот для изучения иностранных слов https://github.com/radostkali/arena-battle-tg-bot - бот Сидорович
Другие примеры использования вы можете найти в здесь https://github.com/odryfox/millet/tree/master/examples