├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── README_en.md ├── dialogic ├── __init__.py ├── adapters │ ├── __init__.py │ ├── alice.py │ ├── base.py │ ├── facebook.py │ ├── salut.py │ ├── text.py │ ├── tg.py │ └── vk.py ├── cascade │ ├── __init__.py │ ├── cascade.py │ └── turn.py ├── criteria.py ├── dialog │ ├── __init__.py │ ├── context.py │ ├── names.py │ ├── phrase.py │ ├── response.py │ └── serialized_message.py ├── dialog_connector.py ├── dialog_manager │ ├── __init__.py │ ├── automaton.py │ ├── base.py │ ├── faq.py │ ├── form_filling.py │ └── turning.py ├── ext │ ├── data_loading.py │ └── google_sheets_api.py ├── interfaces │ ├── __init__.py │ ├── vk.py │ └── yandex │ │ ├── __init__.py │ │ ├── request.py │ │ ├── response.py │ │ └── utils.py ├── nlg │ ├── __init__.py │ ├── controls.py │ ├── morph.py │ ├── reply_markup.py │ └── sampling.py ├── nlu │ ├── __init__.py │ ├── basic_nlu.py │ ├── matchers.py │ ├── regex_expander.py │ └── regex_utils.py ├── server │ ├── __init__.py │ ├── flask_ngrok.py │ └── flask_server.py ├── storage │ ├── __init__.py │ ├── database_utils.py │ ├── message_logging.py │ └── session_storage.py ├── testing │ ├── __init__.py │ └── testing_utils.py └── utils │ ├── __init__.py │ ├── collections.py │ ├── configuration.py │ ├── content_manager.py │ ├── database_utils.py │ ├── serialization.py │ └── text.py ├── examples ├── automaton │ ├── menu.py │ └── menu.yaml ├── cascade │ └── main.py ├── controls.py ├── faq │ ├── faq.py │ └── faq.yaml ├── faq_neural │ ├── README.md │ ├── faq.yaml │ ├── main.py │ ├── models.py │ └── requirements.txt ├── form_filling │ ├── form.py │ └── form.yaml ├── heroku │ ├── Procfile │ └── requirements.txt ├── multimedia.py ├── readme.md ├── state.py ├── vk_api │ ├── readme.md │ ├── vkbot_callback.py │ └── vkbot_polling.py └── yandex_state.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_adapters ├── __init__.py └── test_vk_adapter.py ├── test_cascade.py ├── test_connector.py ├── test_controls.py ├── test_flask_server.py ├── test_image_manager.py ├── test_managers ├── __init__.py ├── faq.yaml ├── form.yaml ├── intents.yaml ├── test_faq.py ├── test_form.py ├── test_fsa.py └── test_turn_dm.py ├── test_message_logging.py ├── test_nlg ├── __init__.py ├── test_morph.py └── test_sampling.py ├── test_nlg_markup.py ├── test_nlu ├── __init__.py ├── expressions.yaml ├── intents.yaml ├── test_matchers.py └── test_regex_expander.py ├── test_phrase.py ├── test_utils.py ├── test_vk_api ├── __init__.py └── test_methods.py └── test_yandex_api ├── test_request.py └── test_response.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | .idea/ 107 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | install: 5 | - python setup.py -q install 6 | - pip install mongomock pymongo pycodestyle pytest-cov numpy pyemd pymorphy2 edlib 7 | script: 8 | - pytest --cov=dialogic --cov-fail-under=75 9 | - pycodestyle . --max-line-length 120 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dialogic 2 | [![PyPI version](https://badge.fury.io/py/dialogic.svg)](https://badge.fury.io/py/dialogic) 3 | 4 | [readme in english](https://github.com/avidale/dialogic/blob/master/README_en.md) 5 | 6 | Это очередная обёртка на Python для навыков 7 | [Алисы](https://yandex.ru/dev/dialogs/alice/doc/about.html) и 8 | [Салюта](https://salute.sber.ru/smartmarket/dev/) (Сбер) и ботов 9 | в Telegram [*](#footnote1), VK, и Facebook. 10 | 11 | Она позволяет как быстро писать прототипы ботов для разных платформ, 12 | так и масштабировать их, когда кода и сценариев становится много. 13 | 14 | Ранее пакет был известен как [tgalice](https://github.com/avidale/tgalice). 15 | 16 | Установка [пакета](https://pypi.org/project/dialogic/): `pip install dialogic` 17 | 18 | ## Пример кода 19 | 20 | Ниже описан бот, который на приветствие отвечает приветствием, 21 | а на все остальные фразы - заглушкой по умолчанию. 22 | 23 | ```python 24 | from dialogic.dialog_connector import DialogConnector 25 | from dialogic.dialog_manager import TurnDialogManager 26 | from dialogic.server.flask_server import FlaskServer 27 | from dialogic.cascade import DialogTurn, Cascade 28 | 29 | csc = Cascade() 30 | 31 | 32 | @csc.add_handler(priority=10, regexp='(hello|hi|привет|здравствуй)') 33 | def hello(turn: DialogTurn): 34 | turn.response_text = 'Привет! Это единственная условная ветка диалога.' 35 | 36 | 37 | @csc.add_handler(priority=1) 38 | def fallback(turn: DialogTurn): 39 | turn.response_text = 'Я вас не понял. Скажите мне "Привет"!' 40 | turn.suggests.append('привет') 41 | 42 | 43 | dm = TurnDialogManager(cascade=csc) 44 | connector = DialogConnector(dialog_manager=dm) 45 | server = FlaskServer(connector=connector) 46 | 47 | if __name__ == '__main__': 48 | server.parse_args_and_run() 49 | ``` 50 | 51 | Чтобы запустить приложение как веб-сервис, достаточно запустить данный скрипт. 52 | 53 | Если приложение доступно по адресу `{BASE_URL}`, 54 | то вебхуки для Алисы, Салюта и Facebook будут доступны, соотвественно, 55 | на `{BASE_URL}/alice/`, `{BASE_URL}/salut/`, and `{BASE_URL}/fb/` 56 | (при желании, адреса можно изменить). 57 | Вебхук для бота в Telegram будет установлен автоматически, 58 | если выставить в переменную окружения `TOKEN` значение, 59 | полученное от [@BotFather](https://t.me/BotFather). 60 | 61 | Чтобы протестировать приложение локально, можно вызвать его с аргументами: 62 | * `--cli` - диалог с ботом в командной строке, полностью онлайн 63 | * `--poll` - запуск бота в Telegram в режиме long polling 64 | * `--ngrok` - локальный запуск с использованием 65 | [ngrok](https://ngrok.com/) [**](#footnote2), 66 | чтобы создать туннель из вашего компьютера во внешний Интернет. 67 | Удобный способ тестировать навыки Алисы. 68 | 69 | Приложение запускается на порте, взятом из переменной окружения `PORT` (по умолчанию 5000). 70 | Для правильной работы с вебхуками нужно будет также выставить переменную окружения `BASE_URL`, описывающую префикс урла, на который будут приходить запросы 71 | (например `https://myapp.herokuapp.com/`). 72 | 73 | Чтобы протестировать работу API для Алисы, не подключая навык, можно сделать такой POST-запрос с помощью CURL: 74 | ``` 75 | curl http://localhost:5000/alice/ -X POST -H "Content-Type: application/json" -d '{"session": {"user_id":"123"}, "request": {"command": "hi"}, "version":"1.0"}' 76 | ``` 77 | на Windows при этом придется заэскейпить кавычки (ибо там работают [только двойные](https://mkyong.com/web/curl-post-json-data-on-windows/)), примерно так: 78 | ``` 79 | curl http://localhost:5000/alice/ -X POST -H "Content-Type: application/json" -d "{\"session\": {\"user_id\":\"123\"}, \"request\": {\"command\": \"hi\"}, \"version\":\"1.0\"}" 80 | ``` 81 | Подробнее про формат запроса для Алисы можно почитать [в её документации](https://yandex.ru/dev/dialogs/alice/doc/request.html). 82 | 83 | ## Больше возможностей 84 | 85 | - Использование встроенных классификаторов интентов или сторонних средств NLU, 86 | включая грамматики от Яндекса или любые доступные в Python модели. 87 | - Подключение собственных поверхностей или настройка имеющихся. 88 | - Логирование запросов и ответов для последующей аналитики. 89 | 90 | Библиотека возможностей регулярно пополняется. 91 | 92 | ## Ресурсы и поддержка 93 | 94 | В папе [examples](https://github.com/avidale/dialogic/tree/master/examples) 95 | собраны примеры использования компонент и запуска ботов. 96 | 97 | Вопросы можно задать в чате 98 | [Dialogic.Digital support](https://t.me/joinchat/WOb48KC6I192zKZu) (Telegram). 99 | 100 | * Обёртка для Telegram использует пакет 101 | [pyTelegramBotAPI](https://github.com/eternnoir/pyTelegramBotAPI). 102 | 103 | ** Обёртка для ngrok была взята из пакета 104 | [flask-ngrok](https://github.com/gstaff/flask-ngrok). 105 | -------------------------------------------------------------------------------- /README_en.md: -------------------------------------------------------------------------------- 1 | # dialogic 2 | [![PyPI version](https://badge.fury.io/py/dialogic.svg)](https://badge.fury.io/py/dialogic) 3 | 4 | This is yet another common Python wrapper for 5 | [Alice](https://yandex.ru/dev/dialogs/alice/doc/about.html) and 6 | [Sber Salut](https://salute.sber.ru/smartmarket/dev/) skills 7 | and bots in Telegram[*](#footnote1), VK, 8 | and Facebook Messenger. 9 | 10 | Currently, it provides: 11 | - An (almost) unified interface between your bot 12 | and Alice/SalutTelegram/Facebook/VK: `DialogConnector`, 13 | for which you can create or customize adapters. 14 | - A number of simple dialogue constructors: `BaseDialogManager` and its flavors, including: 15 | - a simple FAQ dialog manager 16 | - a simple form-filling dialog manager 17 | - a flexible finite state automaton dialog manager 18 | - an even more flexible turn-based dialog manager 19 | - A wrapper for storing dialogue state: `BaseStorage` and its flavors (specifially, `MongoBasedStorage`) 20 | - Yet another wrapper for serving your bot as a Flask application 21 | 22 | This [package](https://pypi.org/project/dialogic/) may be installed with `pip install dialogic`. 23 | 24 | ### A brief how-to 25 | 26 | To create your own bot, you need either to write a config for an existing dialog manager, 27 | or to inherit your own dialog manager from `BaseDialogManager`. 28 | 29 | The components of `dialogic` may be combined into a working app as follows: 30 | 31 | ```python 32 | import dialogic 33 | 34 | class EchoDialogManager(dialogic.dialog_manager.BaseDialogManager): 35 | def respond(self, ctx: dialogic.dialog.Context): 36 | return dialogic.dialog.Response(text=ctx.message_text) 37 | 38 | connector = dialogic.dialog_connector.DialogConnector( 39 | dialog_manager=EchoDialogManager(), 40 | storage=dialogic.session_storage.BaseStorage() 41 | ) 42 | server = dialogic.flask_server.FlaskServer(connector=connector) 43 | 44 | if __name__ == '__main__': 45 | server.parse_args_and_run() 46 | ``` 47 | Now, if your app is hosted on address `{BASE_URL}`, 48 | then webhooks for Alice, Salut and Facebook will be available, 49 | respectively, at `{BASE_URL}/alice/`, `{BASE_URL}/salut/`, and `{BASE_URL}/fb/` (and you can reconfigure it, if you want). 50 | The webhook for Telegram will be set automatically, if you set the `TOKEN` environment variable to the token 51 | given to you by the [@BotFather](https://t.me/BotFather). 52 | 53 | If you want to test your app locally, you can run it with command line args: 54 | * `--cli` - to read and type messages in command line, completely offline 55 | * `--poll` - to run a Telegram bot locally, in long polling mode (in some countries, you need a VPN to do this) 56 | * `--ngrok` - to run the bot locally, using the [ngrok](https://ngrok.com/) tool[**](#footnote2) 57 | to create a tunnel from your machine into the internet. This is probably the simplest way to test Alice skills 58 | without deploying them anywhere . 59 | 60 | The [examples](https://github.com/avidale/dialogic/tree/master/examples) directory contains more detailed examples 61 | of how to create dialogs and serve the bot. 62 | 63 | If you have questions, you can ask them in the Telegram chat 64 | [Dialogic.Digital support](https://t.me/joinchat/WOb48KC6I192zKZu). 65 | 66 | * The Telegram wrapper is based on the 67 | [pyTelegramBotAPI](https://github.com/eternnoir/pyTelegramBotAPI) package. 68 | 69 | ** The ngrok connector was taken from the 70 | [flask-ngrok](https://github.com/gstaff/flask-ngrok) library. It will be refactored to a dependency, 71 | as soon as the library is updated on PyPI. 72 | -------------------------------------------------------------------------------- /dialogic/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from dialogic import adapters, cascade, dialog, dialog_manager, interfaces, \ 3 | nlg, nlu, storage, testing, utils, criteria, dialog_connector 4 | from dialogic.server import flask_server 5 | from dialogic.storage import session_storage, message_logging 6 | from dialogic.dialog_manager.base import COMMANDS 7 | from dialogic.nlu import basic_nlu 8 | 9 | from dialogic.dialog.names import COMMANDS, REQUEST_TYPES, SOURCES 10 | -------------------------------------------------------------------------------- /dialogic/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseAdapter 2 | from .alice import AliceAdapter 3 | from .facebook import FacebookAdapter 4 | from .text import TextAdapter 5 | from .tg import TelegramAdapter 6 | from .vk import VkAdapter 7 | from .salut import SalutAdapter 8 | -------------------------------------------------------------------------------- /dialogic/adapters/alice.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | 3 | from ..adapters.base import BaseAdapter, Context, Response, logger 4 | from ..dialog.names import SOURCES, REQUEST_TYPES, COMMANDS 5 | from ..interfaces.yandex import YandexRequest, YandexResponse 6 | from ..utils.text import encode_uri 7 | from ..utils.content_manager import YandexImageAPI 8 | 9 | 10 | class AliceAdapter(BaseAdapter): 11 | SOURCE = SOURCES.ALICE 12 | 13 | def __init__(self, native_state=False, image_manager: Optional[YandexImageAPI] = None, **kwargs): 14 | super(AliceAdapter, self).__init__(**kwargs) 15 | self.native_state = native_state 16 | self.image_manager: Optional[YandexImageAPI] = image_manager 17 | 18 | def make_context(self, message: Dict, **kwargs) -> Context: 19 | metadata = {} 20 | 21 | if set(message.keys()) == {'body'}: 22 | message = message['body'] 23 | try: 24 | sess = message['session'] 25 | except KeyError: 26 | raise KeyError(f'The key "session" not found in message among keys {list(message.keys())}.') 27 | if sess.get('user', {}).get('user_id'): 28 | # the new user_id, which is persistent across applications 29 | user_id = self.SOURCE + '_auth__' + sess['user']['user_id'] 30 | else: 31 | # the old user id, that changes across applications 32 | user_id = self.SOURCE + '__' + sess['user_id'] 33 | try: 34 | message_text = message['request'].get('command', '') 35 | except KeyError: 36 | raise KeyError(f'The key "request" not found in message among keys {list(message.keys())}.') 37 | metadata['new_session'] = message.get('session', {}).get('new', False) 38 | 39 | ctx = Context( 40 | user_object=None, 41 | message_text=message_text, 42 | metadata=metadata, 43 | user_id=user_id, 44 | session_id=sess.get('session_id'), 45 | source=self.SOURCE, 46 | raw_message=message, 47 | ) 48 | 49 | ctx.request_type = message['request'].get('type', REQUEST_TYPES.SIMPLE_UTTERANCE) 50 | ctx.payload = message['request'].get('payload', {}) 51 | try: 52 | ctx.yandex = YandexRequest.from_dict(message) 53 | except Exception as e: 54 | logger.warning('Could not deserialize Yandex request: got exception "{}".'.format(e)) 55 | 56 | return ctx 57 | 58 | def make_response(self, response: Response, original_message=None, **kwargs): 59 | 60 | directives = {} 61 | if response.commands: 62 | for command in response.commands: 63 | if command == COMMANDS.REQUEST_GEOLOCATION: 64 | directives[COMMANDS.REQUEST_GEOLOCATION] = {} 65 | if response.extra_directives is not None: 66 | directives.update(response.extra_directives) 67 | 68 | result = { 69 | "version": original_message['version'], 70 | "response": { 71 | "end_session": response.has_exit_command, 72 | "text": response.text 73 | } 74 | } 75 | if response.should_listen is not None: 76 | result['response']['should_listen'] = response.should_listen 77 | if self.native_state and response.updated_user_object: 78 | if self.native_state == 'session': 79 | result['session_state'] = response.updated_user_object 80 | elif self.native_state == 'application': 81 | result['application_state'] = response.updated_user_object 82 | elif self.native_state == 'user': 83 | if original_message.get('session') and 'user' not in original_message['session']: 84 | result['application_state'] = response.updated_user_object 85 | result['user_state_update'] = response.updated_user_object 86 | else: 87 | if 'session' in response.updated_user_object: 88 | result['session_state'] = response.updated_user_object['session'] 89 | if 'application' in response.updated_user_object: 90 | result['application_state'] = response.updated_user_object['application'] 91 | if 'user' in response.updated_user_object: 92 | result['user_state_update'] = response.updated_user_object['user'] 93 | if response.raw_response is not None: 94 | if isinstance(response.raw_response, YandexResponse): 95 | result = response.raw_response.to_dict() 96 | else: 97 | result['response'] = response.raw_response 98 | return result 99 | if response.voice is not None and response.voice != response.text: 100 | result['response']['tts'] = response.voice.replace('\n', ' ') 101 | buttons = response.links or [] 102 | for button in buttons: 103 | # avoid cyrillic characters in urls - they are not supported by Alice 104 | if 'url' in button: 105 | button['url'] = encode_uri(button['url']) 106 | if response.suggests: 107 | buttons = buttons + [{'title': suggest} for suggest in response.suggests] 108 | for button in buttons: 109 | if not isinstance(button.get('hide'), bool): 110 | button['hide'] = True 111 | result['response']['buttons'] = buttons 112 | if response.image_id: 113 | result['response']['card'] = { 114 | 'type': 'BigImage', 115 | 'image_id': response.image_id, 116 | 'description': response.text 117 | } 118 | elif response.image_url and self.image_manager: 119 | image_id = self.image_manager.get_image_id_by_url(response.image_url) 120 | if image_id: 121 | result['response']['card'] = { 122 | 'type': 'BigImage', 123 | 'image_id': image_id, 124 | 'description': response.text 125 | } 126 | if response.gallery is not None: 127 | result['response']['card'] = response.gallery.to_dict() 128 | if response.image is not None: 129 | result['response']['card'] = response.image.to_dict() 130 | if response.show_item_meta is not None: 131 | result['response']['show_item_meta'] = response.show_item_meta 132 | if directives: 133 | result['response']['directives'] = directives 134 | return result 135 | 136 | def uses_native_state(self, context: Context) -> bool: 137 | """ Whether dialog state can be extracted directly from a context""" 138 | return bool(self.native_state) 139 | 140 | def get_native_state(self, context: Context) -> Optional[Dict]: 141 | """ Return native dialog state if it is possible""" 142 | if not self.native_state: 143 | return 144 | message = context.raw_message or {} 145 | state = message.get('state', {}) 146 | 147 | if self.native_state == 'session': 148 | user_object = state.get('session') 149 | elif self.native_state == 'user': 150 | user_object = state.get('user') 151 | # for unauthorized users, use application state instead 152 | if message.get('session') and 'user' not in message['session']: 153 | user_object = state.get('application') 154 | elif self.native_state == 'application': 155 | user_object = state.get('application') 156 | else: 157 | user_object = state 158 | return user_object 159 | -------------------------------------------------------------------------------- /dialogic/adapters/base.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | from typing import Optional, Dict 4 | 5 | from ..dialog.serialized_message import SerializedMessage 6 | from ..dialog import Context, Response 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class BaseAdapter: 13 | """ A base class for adapters that encode and decode messages into a single format """ 14 | 15 | def make_context(self, message, **kwargs) -> Context: 16 | """ Get a raw `message` in a platform-specific format and encode it into a unified `Context` """ 17 | raise NotImplementedError() 18 | 19 | def make_response(self, response: Response, original_message=None, **kwargs): 20 | """ Get a unified `Response` object and decode it into the platform-specific response """ 21 | raise NotImplementedError() 22 | 23 | def serialize_context(self, context: Context, data=None, **kwargs) -> Optional[SerializedMessage]: 24 | if data is None: 25 | data = context.raw_message 26 | if context.request_id is not None: 27 | kwargs['request_id'] = context.request_id 28 | return SerializedMessage( 29 | text=context.message_text, 30 | user_id=context.user_id, 31 | from_user=True, 32 | data=data, 33 | source=context.source, 34 | session_id=context.session_id, 35 | **kwargs 36 | ) 37 | 38 | def serialize_response(self, data, context: Context, response: Response, **kwargs) -> Optional[SerializedMessage]: 39 | data = copy.deepcopy(data) 40 | if context.request_id is not None: 41 | kwargs['request_id'] = context.request_id 42 | if response.label: 43 | kwargs['label'] = response.label 44 | return SerializedMessage( 45 | text=response.text, 46 | user_id=context.user_id, 47 | from_user=False, 48 | data=data, 49 | source=context.source, 50 | session_id=context.session_id, 51 | **kwargs 52 | ) 53 | 54 | def uses_native_state(self, context: Context) -> bool: 55 | """ Whether dialog state can be extracted directly from a context""" 56 | return False 57 | 58 | def get_native_state(self, context: Context) -> Optional[Dict]: 59 | """ Return native dialog state if it is possible""" 60 | return 61 | -------------------------------------------------------------------------------- /dialogic/adapters/facebook.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from dialogic.dialog.names import SOURCES, REQUEST_TYPES 4 | from dialogic.adapters.base import BaseAdapter, Context, Response, logger 5 | 6 | 7 | class FacebookAdapter(BaseAdapter): 8 | SOURCE = SOURCES.FACEBOOK 9 | 10 | def make_context(self, message: Dict, **kwargs) -> Context: 11 | uid = self.SOURCE + '__' + message['sender']['id'] 12 | ctx = Context( 13 | user_object=None, 14 | message_text=message.get('message', {}).get('text', ''), 15 | metadata={}, 16 | user_id=uid, 17 | session_id=uid, 18 | source=self.SOURCE, 19 | raw_message=message, 20 | ) 21 | 22 | if not message.get('message', {}).get('text', ''): 23 | payload = message.get('postback', {}).get('payload') 24 | if payload is not None: 25 | ctx.payload = payload 26 | ctx.request_type = REQUEST_TYPES.BUTTON_PRESSED # todo: check if it is really the case 27 | # todo: do something in case of attachments (message['message'].get('attachments')) 28 | # if user sends us a GIF, photo,video, or any other non-text item 29 | 30 | return ctx 31 | 32 | def make_response(self, response: Response, original_message=None, **kwargs): 33 | if response.raw_response is not None: 34 | return response.raw_response 35 | result = {'text': response.text} 36 | if response.suggests or response.links: 37 | links = [{'type': 'web_url', 'title': link['title'], 'url': link['url']} for link in response.links] 38 | suggests = [{'type': 'postback', 'title': s, 'payload': s} for s in response.suggests] 39 | result = { 40 | "attachment": { 41 | "type": "template", 42 | "payload": { 43 | "template_type": "button", 44 | "text": response.text, 45 | "buttons": links + suggests 46 | } 47 | } 48 | } 49 | return result # for bot.send_message(recipient_id, result) 50 | -------------------------------------------------------------------------------- /dialogic/adapters/salut.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from typing import Dict 4 | 5 | from ..adapters.base import BaseAdapter, Context, Response 6 | from ..dialog.names import SOURCES, COMMANDS 7 | 8 | 9 | class SalutAdapter(BaseAdapter): 10 | SOURCE = SOURCES.SALUT 11 | 12 | def __init__(self, split_bubbles='\n\n', **kwargs): 13 | super(SalutAdapter, self).__init__(**kwargs) 14 | self.split_bubbles = split_bubbles 15 | 16 | def make_context(self, message: Dict, **kwargs) -> Context: 17 | metadata = {} 18 | 19 | user = message.get('uuid') 20 | payload = message.get('payload') or {} 21 | pm = payload.get('message') 22 | 23 | user_id = self.SOURCE + '__' + user.get('userId') 24 | 25 | message_text = (pm or {}).get('original_text') or '' 26 | 27 | metadata['new_session'] = payload.get('new_session') 28 | 29 | ctx = Context( 30 | user_object=None, 31 | message_text=message_text, 32 | metadata=metadata, 33 | user_id=user_id, 34 | session_id=message.get('sessionId'), 35 | source=self.SOURCE, 36 | raw_message=message, 37 | ) 38 | 39 | # ctx.request_type = message['request'].get('type', REQUEST_TYPES.SIMPLE_UTTERANCE) 40 | # ctx.payload = message['request'].get('payload', {}) 41 | 42 | # todo: add a structured Salut request 43 | return ctx 44 | 45 | def make_response(self, response: Response, original_message=None, **kwargs): 46 | original_message = original_message or {} 47 | original_payload = original_message.get('payload') or {} 48 | 49 | items = [] 50 | 51 | if response.text: 52 | if self.split_bubbles: 53 | texts = response.text.split(self.split_bubbles) 54 | else: 55 | texts = [response.text] 56 | for t in texts: 57 | items.append({'bubble': {'text': t}}) 58 | 59 | buttons = [] 60 | if response.links: 61 | for s in response.links: 62 | buttons.append({'title': s['title'], 'action': {'deep_link': s['url'], 'type': 'deep_link'}}) 63 | if response.suggests: 64 | for s in response.suggests: 65 | buttons.append({'title': s, 'action': {'text': s, 'type': 'text'}}) 66 | 67 | # todo: add cards 68 | 69 | if response.voice: 70 | voice = re.sub('<.*>', ' ', response.voice).strip().replace('\n', ' ') 71 | else: 72 | voice = response.text 73 | 74 | payload = { 75 | 'pronounceText': voice, 76 | # pronounceTextType 77 | # emotion 78 | 'items': items, 79 | 'device': original_payload.get('device'), 80 | 'auto_listening': True, 81 | 'finished': bool(COMMANDS.EXIT in response.commands), 82 | # intent 83 | # asr_hints 84 | } 85 | if buttons: 86 | payload['suggestions'] = {'buttons': buttons} 87 | 88 | result = { 89 | "messageName": "ANSWER_TO_USER", 90 | "sessionId": original_message.get('sessionId'), 91 | "messageId": original_message.get('messageId'), 92 | "uuid": original_message.get('uuid'), 93 | "payload": payload 94 | } 95 | 96 | return result 97 | -------------------------------------------------------------------------------- /dialogic/adapters/text.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from dialogic.dialog.names import SOURCES, REQUEST_TYPES 4 | from dialogic.adapters.base import BaseAdapter, Context, Response, logger 5 | 6 | 7 | class TextAdapter(BaseAdapter): 8 | SOURCE = SOURCES.TEXT 9 | 10 | def make_context(self, message: Dict, **kwargs) -> Context: 11 | ctx = Context( 12 | user_object=None, 13 | message_text=message, 14 | metadata={}, 15 | user_id='the_text_user', 16 | session_id='the_text_session', 17 | source=self.SOURCE, 18 | raw_message=message, 19 | ) 20 | return ctx 21 | 22 | def make_response(self, response: Response, original_message=None, **kwargs): 23 | result = response.text 24 | if response.voice is not None and response.voice != response.text: 25 | result = result + '\n[voice: {}]'.format(response.voice) 26 | if response.image_id: 27 | result = result + '\n[image: {}]'.format(response.image_id) 28 | if response.image_url: 29 | result = result + '\n[image: {}]'.format(response.image_url) 30 | if response.sound_url: 31 | result = result + '\n[sound: {}]'.format(response.sound_url) 32 | if len(response.links) > 0: 33 | result = result + '\n' + ', '.join( 34 | ['[{}: {}]'.format(link['title'], link['url']) for link in response.links] 35 | ) 36 | if len(response.suggests) > 0: 37 | result = result + '\n' + ', '.join(['[{}]'.format(s) for s in response.suggests]) 38 | if len(response.commands) > 0: 39 | result = result + '\n' + ', '.join(['{{{}}}'.format(c) for c in response.commands]) 40 | return [result, response.has_exit_command] 41 | -------------------------------------------------------------------------------- /dialogic/adapters/tg.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Dict, Optional 3 | 4 | import telebot 5 | from ..dialog.serialized_message import SerializedMessage 6 | from ..dialog.names import SOURCES 7 | from dialogic.adapters.base import BaseAdapter, Context, Response, logger 8 | from telebot.types import Message 9 | 10 | 11 | class TelegramAdapter(BaseAdapter): 12 | SOURCE = SOURCES.TELEGRAM 13 | 14 | def __init__(self, suggest_cols=1, **kwargs): 15 | super(TelegramAdapter, self).__init__(**kwargs) 16 | self.suggest_cols = suggest_cols 17 | 18 | def make_context(self, message: Message, **kwargs) -> Context: 19 | uid = self.SOURCE + '__' + str(message.from_user.id) 20 | ctx = Context( 21 | user_object=None, 22 | message_text=message.text, 23 | metadata={}, 24 | user_id=uid, 25 | session_id=uid, 26 | source=self.SOURCE, 27 | raw_message=message, 28 | ) 29 | return ctx 30 | 31 | def make_response(self, response: Response, original_message=None, **kwargs): 32 | if response.raw_response is not None: 33 | return response.raw_response 34 | result = { 35 | 'text': response.text 36 | } 37 | if response.links is not None: 38 | result['parse_mode'] = 'html' 39 | for link in response.links: 40 | result['text'] += '\n{}'.format(link['url'], link['title']) 41 | if response.suggests: 42 | # todo: do smarter row width calculation 43 | row_width = min(self.suggest_cols or 1, len(response.suggests)) 44 | result['reply_markup'] = telebot.types.ReplyKeyboardMarkup(row_width=row_width) 45 | result['reply_markup'].add(*[telebot.types.KeyboardButton(t) for t in response.suggests]) 46 | else: 47 | result['reply_markup'] = telebot.types.ReplyKeyboardRemove(selective=False) 48 | if response.image_url: 49 | if 'multimedia' not in result: 50 | result['multimedia'] = [] 51 | media_type = 'document' if response.image_url.endswith('.gif') else 'photo' 52 | result['multimedia'].append({'type': media_type, 'content': response.image_url}) 53 | if response.sound_url: 54 | if 'multimedia' not in result: 55 | result['multimedia'] = [] 56 | result['multimedia'].append({'type': 'audio', 'content': response.sound_url}) 57 | result['disable_web_page_preview'] = True 58 | return result 59 | 60 | def serialize_context(self, context: Context, data=None, **kwargs) -> Optional[SerializedMessage]: 61 | message = context.raw_message 62 | if message and message.reply_to_message is not None: 63 | kwargs['reply_to_id'] = message.reply_to_message.message_id 64 | kwargs['message_id'] = message and message.message_id 65 | kwargs['username'] = message and message.chat.username 66 | serializable_message = {'message': str(message)} 67 | return super(TelegramAdapter, self).serialize_context(context=context, data=serializable_message, **kwargs) 68 | 69 | def serialize_response(self, data, context: Context, response: Response, **kwargs) -> Optional[SerializedMessage]: 70 | data = copy.deepcopy(data) 71 | message = context.raw_message 72 | kwargs['reply_to_id'] = message and message.message_id 73 | kwargs['username'] = message and message.chat.username 74 | # todo: maybe somehow get message_id for output messages 75 | if 'reply_markup' in data: 76 | data['reply_markup'] = data['reply_markup'].to_json() 77 | return super(TelegramAdapter, self).serialize_response( 78 | data=data, 79 | context=context, 80 | response=response, 81 | kwargs=kwargs 82 | ) 83 | -------------------------------------------------------------------------------- /dialogic/adapters/vk.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import Optional 3 | 4 | from ..dialog.serialized_message import SerializedMessage 5 | from ..dialog.names import SOURCES 6 | from ..adapters.base import BaseAdapter, Context, Response 7 | 8 | 9 | class VkAdapter(BaseAdapter): 10 | SOURCE = SOURCES.VK 11 | 12 | def __init__( 13 | self, 14 | suggest_cols='auto', suggest_screen=32, suggest_margin=1, suggest_max_len=40, 15 | suggest_max_cols=5, suggest_max_rows=10, 16 | suggest_add_payload=False, 17 | **kwargs 18 | ): 19 | super(VkAdapter, self).__init__(**kwargs) 20 | self.suggest_cols = suggest_cols 21 | self.suggest_screen = suggest_screen 22 | self.suggest_margin = suggest_margin 23 | self.suggest_max_len = suggest_max_len 24 | self.suggest_max_cols = suggest_max_cols 25 | self.suggest_max_rows = suggest_max_rows 26 | self.suggest_add_payload = suggest_add_payload 27 | 28 | def make_context(self, message, **kwargs) -> Context: 29 | uid = self.SOURCE + '__' + str(message.user_id) 30 | ctx = Context( 31 | user_object=None, 32 | message_text=message.text, 33 | metadata={}, 34 | user_id=uid, 35 | session_id=uid, 36 | source=self.SOURCE, 37 | raw_message=message, 38 | ) 39 | return ctx 40 | 41 | def make_response(self, response: Response, original_message=None, **kwargs): 42 | # todo: instead of a dict, use a class object as a response 43 | # todo: add multimedia, etc. 44 | result = { 45 | 'text': response.text, 46 | } 47 | if response.suggests or response.links: 48 | buttons = [] 49 | for i, link in enumerate(response.links): 50 | buttons.append({'action': {'type': 'open_link', 'label': link['title'], 'link': link['url']}}) 51 | for i, suggest in enumerate(response.suggests): 52 | a = {'type': 'text', 'label': suggest} 53 | if self.suggest_add_payload: 54 | a['payload'] = suggest 55 | buttons.append({'action': a}) 56 | 57 | rows = [] 58 | row_width = 0 59 | for i, button in enumerate(buttons): 60 | if self.suggest_cols == 'auto': 61 | extra_width = len(button['action']['label']) + self.suggest_margin * 2 62 | if len(rows) == 0 or row_width > 0 and row_width + extra_width > self.suggest_screen \ 63 | or len(rows[-1]) >= self.suggest_max_cols: 64 | rows.append([]) 65 | row_width = extra_width 66 | else: 67 | row_width += extra_width 68 | else: 69 | if i % self.suggest_cols == 0: 70 | rows.append([]) 71 | rows[-1].append(button) 72 | 73 | for row in rows: 74 | for button in row: 75 | label = button['action']['label'] 76 | if len(label) > self.suggest_max_len: 77 | button['action']['label'] = label[:(self.suggest_max_len - 1)] + '…' 78 | 79 | if self.suggest_max_rows: 80 | rows = self.squeeze_keyboard(rows) 81 | rows = rows[:self.suggest_max_rows] 82 | 83 | result['keyboard'] = { 84 | 'one_time': True, 85 | 'buttons': rows, 86 | } 87 | return result 88 | 89 | def squeeze_keyboard(self, rows): 90 | """ Shorten some buttons so that all buttons fit into the screen """ 91 | for _ in range(100): 92 | if len(rows) <= self.suggest_max_rows: 93 | break 94 | # estimate free space in each row 95 | potentials = [ 96 | sum([len(button['action']['label']) + self.suggest_margin * 2 for button in row]) 97 | for row in rows 98 | ] 99 | # find two neighbor rows with most total free space 100 | best_pot = 1000 101 | best_i = 0 102 | for i in range(1, len(rows)): 103 | pot = potentials[i - 1] + potentials[i] 104 | if pot <= best_pot and len(rows[i-1]) + len(rows[i]) <= self.suggest_max_cols: 105 | best_pot, best_i = pot, i 106 | if best_i == 0: 107 | break 108 | # calculate button sizes if the rows are merged 109 | to_reduce = best_pot - self.suggest_screen 110 | new_buttons = [copy.deepcopy(b) for row in rows[best_i - 1:best_i + 1] for b in row] 111 | sizes = [len(b['action']['label']) for b in new_buttons] 112 | while to_reduce > 0 and max(sizes) > 3: 113 | new_sizes = [] 114 | maxsize = max(sizes) 115 | for s in sizes: 116 | if s == maxsize and to_reduce: 117 | s -= 1 118 | to_reduce -= 1 119 | new_sizes.append(s) 120 | sizes = new_sizes 121 | # actually, reduce the buttons 122 | for new_size, b in zip(sizes, new_buttons): 123 | n = len(b['action']['label']) 124 | if n > new_size: 125 | b['action']['label'] = b['action']['label'][:new_size - 1] + '…' 126 | # update the rows 127 | rows = rows[:best_i - 1] + [new_buttons] + rows[best_i + 1:] 128 | return rows 129 | 130 | def serialize_context(self, context: Context, data=None, **kwargs) -> Optional[SerializedMessage]: 131 | serializable_message = {'message': context.raw_message and context.raw_message.to_json()} 132 | return super(VkAdapter, self).serialize_context(context=context, data=serializable_message, **kwargs) 133 | -------------------------------------------------------------------------------- /dialogic/cascade/__init__.py: -------------------------------------------------------------------------------- 1 | from . import cascade, turn 2 | from .cascade import Cascade, Pr 3 | from .turn import DialogTurn 4 | -------------------------------------------------------------------------------- /dialogic/cascade/cascade.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import attr 4 | import logging 5 | import math 6 | 7 | from typing import Callable, List, Optional, Dict 8 | 9 | from .turn import DialogTurn 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | CHECKER_TYPE = Callable[[DialogTurn], bool] 15 | HANDLER_TYPE = Callable[[DialogTurn], bool] 16 | POSTPROCESSOR_TYPE = Callable[[DialogTurn, Optional[Dict]], None] 17 | 18 | 19 | class Pr: 20 | EPSILON = 1e-5 21 | CRITICAL = 100 22 | STAGE = 10 23 | STRONG_INTENT = 5 # like 'cancel' for multi-turn scenarios 24 | WEAK_STAGE = 3 # stages in multi-turn scenarios that can be easily cancelled 25 | CHECKER = 2 26 | INTENT_PLUS = 1.1 # local intents, that should compete with each other but not with Yandex intents 27 | INTENT = 1 28 | INTENT_MINUS = 0.9 29 | FAQ = 0.1 30 | FIND_ANYTHING = 0.05 31 | BEFORE_FALLBACK = 0.03 32 | FALLBACK = 0.01 33 | 34 | 35 | @attr.s 36 | class CascadeItem: 37 | handler: HANDLER_TYPE = attr.ib() 38 | priority: float = attr.ib() 39 | intents: Optional[List[str]] = attr.ib(factory=list) 40 | stages: Optional[List[str]] = attr.ib(factory=list) 41 | checker: Optional[CHECKER_TYPE] = attr.ib(default=None) 42 | regexp: Optional[str] = attr.ib(default=None) 43 | 44 | 45 | class Cascade: 46 | def __init__(self): 47 | self.handlers: List[CascadeItem] = [] 48 | self.postprocessors: Dict[str, POSTPROCESSOR_TYPE] = {} 49 | 50 | def add_handler( 51 | self, 52 | priority=None, 53 | intents=None, 54 | stages=None, 55 | checker=None, 56 | regexp=None, 57 | ) -> Callable[[HANDLER_TYPE], HANDLER_TYPE]: 58 | if priority is None: 59 | if stages: 60 | priority = Pr.STAGE 61 | elif intents or regexp: 62 | priority = Pr.INTENT 63 | elif checker: 64 | priority = Pr.CHECKER 65 | else: 66 | priority = Pr.FALLBACK 67 | 68 | def wrap(f: HANDLER_TYPE): 69 | self.handlers.append(CascadeItem( 70 | handler=f, 71 | priority=priority, 72 | intents=intents, 73 | stages=stages, 74 | checker=checker, 75 | regexp=regexp 76 | )) 77 | return f 78 | return wrap 79 | 80 | def __call__(self, turn: DialogTurn) -> Optional[str]: 81 | if turn.is_complete: 82 | return None 83 | candidates = [] 84 | for item in self.handlers: 85 | # stages are matched strictly 86 | if item.stages: 87 | if turn.prev_stage not in item.stages: 88 | continue 89 | # intent scores are matched strictly and then sorted 90 | intent_score = -math.inf 91 | if item.intents: 92 | intent_score = max(turn.intents.get(intent, -math.inf) for intent in item.intents) 93 | if intent_score < 1 and item.regexp and turn.text is not None: 94 | if re.match(item.regexp, turn.text): 95 | intent_score = 1 96 | if (item.intents or item.regexp) and intent_score == -math.inf: 97 | continue 98 | candidates.append((item.priority, intent_score, item)) 99 | candidates.sort(key=lambda x: x[:2], reverse=True) 100 | logger.debug('sorted candidates: {}'.format([c[2].handler.__name__ for c in candidates])) 101 | 102 | for candidate in candidates: 103 | item = candidate[2] 104 | if item.checker and not item.checker(turn): 105 | continue 106 | result = item.handler(turn) 107 | if turn.is_complete: 108 | return item.handler.__name__ 109 | return None 110 | 111 | def add_postprocessor(self, name: str, function: POSTPROCESSOR_TYPE): 112 | self.postprocessors[name] = function 113 | 114 | def get_postprocessor(self, name: str) -> Optional[POSTPROCESSOR_TYPE]: 115 | return self.postprocessors.get(name) 116 | 117 | def postprocessor(self, function: POSTPROCESSOR_TYPE): 118 | # this will be used as a decorator 119 | name = function.__name__ 120 | if name in self.postprocessors: 121 | logger.warning( 122 | 'registering postprocessor "{}" for a second time'.format(name) 123 | ) 124 | self.add_postprocessor(name, function) 125 | return function 126 | 127 | def postprocess(self, turn: DialogTurn): 128 | if turn.is_complete and turn.can_take_control and turn.agenda: 129 | key, form = turn.pop_agenda() 130 | logger.debug('agenda key is: {}'.format(key)) 131 | if not key: 132 | return 133 | f = self.get_postprocessor(key) 134 | if not f: 135 | return 136 | if form: 137 | f(turn, form=form) 138 | else: 139 | f(turn) 140 | -------------------------------------------------------------------------------- /dialogic/cascade/turn.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import logging 3 | 4 | from dialogic.dialog import Context, Response 5 | from dialogic.dialog.names import COMMANDS 6 | from dialogic.utils.content_manager import YandexImageAPI 7 | from dialogic.nlg.controls import Gallery as VisualGallery, BigImage, ImageGallery 8 | from typing import Dict, List, Optional, Union, Tuple 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | FORM_TYPE = Dict[str, Dict] 14 | 15 | 16 | @attr.s 17 | class DialogTurn: 18 | """ DialogTurn is a single-variable wrapper for both context and response """ 19 | ctx: Context = attr.ib() 20 | # These properties are extracted from context: 21 | text: str = attr.ib() 22 | intents: Dict = attr.ib(factory=dict) 23 | forms: Dict[str, Dict] = attr.ib(factory=dict) 24 | # These properties will be put into response: 25 | response_text: str = attr.ib(default='') 26 | response: Optional[Response] = attr.ib(default=None) 27 | user_object = attr.ib(factory=dict) 28 | suggests: List = attr.ib(factory=list) 29 | commands: List = attr.ib(factory=list) 30 | card: Optional[Union[VisualGallery, BigImage]] = attr.ib(default=None) 31 | image_url: Optional[str] = attr.ib(default=None) 32 | image_id: str = attr.ib(default=None) 33 | show_item_meta = attr.ib(default=None) 34 | extra_directives: Optional[Dict] = attr.ib(default=None) 35 | should_listen: Optional[bool] = attr.ib(default=None) 36 | # These properties are helpers 37 | image_manager: Optional[YandexImageAPI] = attr.ib(default=None) 38 | can_change_topic: bool = attr.ib(default=False) 39 | 40 | _STAGE = 'stage' 41 | _AGENDA = 'agenda' 42 | _AGENDA_FORMS = 'agenda_forms' 43 | 44 | @property 45 | def old_user_object(self): 46 | return self.ctx.user_object or {} 47 | 48 | @property 49 | def is_complete(self): 50 | return bool(self.response_text) or bool(self.response) 51 | 52 | @property 53 | def stage(self) -> Optional[str]: 54 | return self.old_user_object.get(self._STAGE) 55 | 56 | @property 57 | def next_stage(self) -> Optional[str]: 58 | return self.user_object.get(self._STAGE) 59 | 60 | @next_stage.setter 61 | def next_stage(self, value): 62 | self.user_object[self._STAGE] = value 63 | 64 | @stage.setter 65 | def stage(self, value): 66 | self.user_object[self._STAGE] = value 67 | 68 | @property 69 | def prev_stage(self) -> Optional[str]: 70 | return self.old_user_object.get(self._STAGE) 71 | 72 | @prev_stage.setter 73 | def prev_stage(self, value): 74 | self.old_user_object[self._STAGE] = value 75 | 76 | def release_control(self): 77 | self.can_change_topic = True 78 | 79 | def take_control(self): 80 | self.can_change_topic = False 81 | 82 | @property 83 | def can_take_control(self) -> bool: 84 | return self.can_change_topic and not self.next_stage 85 | 86 | @property 87 | def agenda(self) -> List[str]: 88 | """ The stack of postprocessors waiting to be triggered """ 89 | return self.user_object.get(self._AGENDA, []) 90 | 91 | @property 92 | def agenda_forms(self) -> FORM_TYPE: 93 | """ The forms to be fed into postprocessors """ 94 | return self.user_object.get(self._AGENDA_FORMS, {}) 95 | 96 | def add_agenda(self, postprocessor_name: str, form: Dict = None) -> bool: 97 | if not self.agenda: 98 | self.user_object[self._AGENDA] = [] 99 | if postprocessor_name not in self.agenda: 100 | # avoiding duplicate agenda and potential cycles 101 | self.agenda.append(postprocessor_name) 102 | if form: 103 | if not self.agenda_forms: 104 | self.user_object[self._AGENDA_FORMS] = {} 105 | self.agenda_forms[postprocessor_name] = form 106 | return True 107 | return False 108 | 109 | def pop_agenda(self) -> Tuple[Optional[str], Optional[FORM_TYPE]]: 110 | if not self.agenda: 111 | self.clear_agenda() 112 | return None, None 113 | key = self.agenda.pop() 114 | form = self.agenda_forms.get(key) 115 | if form: 116 | del self.agenda_forms[key] 117 | return key, form 118 | 119 | def clear_agenda(self): 120 | self.user_object[self._AGENDA] = [] 121 | self.user_object[self._AGENDA_FORMS] = {} 122 | 123 | def add_space(self): 124 | if not self.response_text: 125 | self.response_text = '' 126 | else: 127 | self.response_text += '\n' 128 | 129 | def make_response(self) -> Optional[Response]: 130 | if self.response: 131 | return self.response 132 | if self.response_text: 133 | r = Response( 134 | text=None, 135 | user_object=self.user_object, 136 | rich_text=self.response_text, 137 | suggests=self.suggests, 138 | commands=self.commands, 139 | image_url=self.image_url, 140 | show_item_meta=self.show_item_meta, 141 | extra_directives=self.extra_directives, 142 | should_listen=self.should_listen, 143 | ) 144 | if isinstance(self.card, BigImage): 145 | r.image = self.card 146 | elif isinstance(self.card, (VisualGallery, ImageGallery)): 147 | r.gallery = self.card 148 | if self.image_id: 149 | r.image_id = self.image_id 150 | return r 151 | 152 | def exit(self): 153 | self.commands.append(COMMANDS.EXIT) 154 | -------------------------------------------------------------------------------- /dialogic/criteria.py: -------------------------------------------------------------------------------- 1 | from .dialog import Context 2 | from .dialog.names import REQUEST_TYPES 3 | 4 | 5 | def is_morning_show_context(ctx: Context) -> bool: 6 | if not ctx.yandex or not ctx.yandex.request: 7 | return False 8 | r = ctx.yandex.request 9 | if r.type != REQUEST_TYPES.SHOW_PULL: 10 | return False 11 | return r.show_type == 'MORNING' 12 | -------------------------------------------------------------------------------- /dialogic/dialog/__init__.py: -------------------------------------------------------------------------------- 1 | from . import context, names, response 2 | from .context import Context 3 | from .response import Response 4 | -------------------------------------------------------------------------------- /dialogic/dialog/context.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import uuid 4 | 5 | from typing import Optional 6 | 7 | from .names import REQUEST_TYPES, SOURCES 8 | from dialogic.interfaces.yandex import YandexRequest 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Context: 15 | def __init__( 16 | self, user_object, message_text, metadata, request_id=None, user_id=None, source=None, raw_message=None, 17 | request_type=REQUEST_TYPES.SIMPLE_UTTERANCE, payload=None, yandex=None, 18 | session_id=None, 19 | ): 20 | self._user_object = copy.deepcopy(user_object) 21 | self.message_text = message_text 22 | self.metadata = metadata 23 | self.request_id = request_id or str(uuid.uuid1()) 24 | self.user_id = user_id 25 | self.session_id = session_id 26 | self.source = source 27 | self.raw_message = raw_message 28 | self.request_type = request_type 29 | self.payload = payload 30 | self.yandex: Optional[YandexRequest] = yandex 31 | 32 | @property 33 | def user_object(self): 34 | # todo: make this object explicitly frozen 35 | return self._user_object 36 | 37 | def add_user_object(self, user_object): 38 | self._user_object = copy.deepcopy(user_object) 39 | 40 | def session_is_new(self): 41 | # todo: define new session for non-Alice sources as well 42 | return bool(self.metadata.get('new_session')) 43 | 44 | @classmethod 45 | def from_raw(cls, source, message): 46 | raise DeprecationWarning('This method is not used anymore. Please use the adapters subpackage instead.') 47 | -------------------------------------------------------------------------------- /dialogic/dialog/names.py: -------------------------------------------------------------------------------- 1 | 2 | class SOURCES: 3 | ALICE = 'alice' 4 | FACEBOOK = 'facebook' 5 | SALUT = 'salut' 6 | TELEGRAM = 'telegram' 7 | TEXT = 'text' 8 | VK = 'vk' 9 | unknown_source_error_message = 'Source must be on of {"alice", "facebook", "telegram", "text", "vk"}' 10 | 11 | 12 | class COMMANDS: 13 | EXIT = 'exit' 14 | REQUEST_GEOLOCATION = 'request_geolocation' 15 | 16 | 17 | class REQUEST_TYPES: 18 | SIMPLE_UTTERANCE = 'SimpleUtterance' 19 | PUSH = 'push' 20 | BUTTON_PRESSED = 'ButtonPressed' 21 | SHOW_PULL = 'Show.Pull' 22 | GEOLOCATION_ALLOWED = 'Geolocation.Allowed' 23 | GEOLOCATION_REJECTED = 'Geolocation.Rejected' 24 | -------------------------------------------------------------------------------- /dialogic/dialog/phrase.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Mapping 3 | 4 | from dialogic.dialog import Response, names 5 | 6 | 7 | class Phrase: 8 | """ This is a template for Response 9 | It has some methods to be parsed from configs. 10 | It generates a Response possibly by sampling options. 11 | """ 12 | def __init__(self, name, text, exit=False, suggests=None): 13 | self.name = name 14 | if isinstance(text, list): 15 | self.texts = text 16 | else: 17 | self.texts = [text] 18 | self.exit = exit 19 | self.suggests = suggests or [] 20 | # todo: read everything that can be found in response config 21 | 22 | def render(self, seed=None, additional_suggests=None): 23 | if seed is not None: 24 | random.seed(seed) 25 | text = random.choice(self.texts) 26 | resp = Response(text, suggests=self.suggests) 27 | resp.set_text(text) 28 | if self.exit: 29 | resp.commands.append(names.COMMANDS.EXIT) 30 | if additional_suggests: 31 | resp.suggests.extend(additional_suggests) 32 | return resp 33 | 34 | @classmethod 35 | def from_object(cls, obj): 36 | if isinstance(obj, str): 37 | return cls(name='unnamed', text=obj) 38 | elif isinstance(obj, Mapping): 39 | return cls(**obj) 40 | elif isinstance(obj, cls): 41 | return obj 42 | else: 43 | raise ValueError('Type {} cannot be converted to Phrase'.format(type(obj))) 44 | -------------------------------------------------------------------------------- /dialogic/dialog/response.py: -------------------------------------------------------------------------------- 1 | from dialogic.nlg import reply_markup, controls 2 | from dialogic.dialog.names import COMMANDS 3 | 4 | 5 | class Response: 6 | def __init__( 7 | self, 8 | text, 9 | suggests=None, 10 | commands=None, 11 | voice=None, 12 | links=None, 13 | image_id=None, 14 | image_url=None, 15 | sound_url=None, 16 | gallery=None, 17 | image=None, 18 | user_object=None, 19 | raw_response=None, 20 | confidence=0.5, 21 | label=None, 22 | handler=None, 23 | rich_text=None, 24 | show_item_meta=None, 25 | no_response=False, # whether the response (in messenger) should be emtpy 26 | attachment_filename=None, 27 | extra_directives=None, 28 | should_listen=None, 29 | ): 30 | self.text = text 31 | self.suggests = suggests or [] 32 | self.commands = commands or [] 33 | self.voice = voice if voice is not None else text 34 | self.links = links or [] 35 | self.updated_user_object = user_object 36 | self.confidence = confidence 37 | self.image_id = image_id 38 | self.image_url = image_url # todo: support them in Facebook as well 39 | self.sound_url = sound_url 40 | self.gallery = gallery 41 | assert self.gallery is None or isinstance(self.gallery, (controls.Gallery, controls.ImageGallery)) 42 | self.image = image 43 | assert self.image is None or isinstance(self.image, controls.BigImage) 44 | self.raw_response = raw_response 45 | self.handler = handler 46 | self.label = label 47 | if rich_text: 48 | self.set_text(rich_text) 49 | self.show_item_meta = show_item_meta 50 | self.no_response = no_response 51 | self.attachment_filename = attachment_filename 52 | self.extra_directives = extra_directives # a dictionary of directives for Alice 53 | self.should_listen = should_listen 54 | 55 | @property 56 | def user_object(self): 57 | # make it readonly, for clarity 58 | return self.updated_user_object 59 | 60 | def set_rich_text(self, rich_text): 61 | parser = reply_markup.TTSParser() 62 | try: 63 | parser.feed(rich_text) 64 | except ValueError as e: 65 | raise ValueError('Got error "{}" while parsing text "{}"'.format(e, rich_text)) 66 | parser.close() 67 | self.text = parser.get_text() 68 | self.voice = parser.get_voice() 69 | self.links.extend(parser.get_links()) 70 | if parser.get_image_id(): 71 | self.image_id = parser.get_image_id() 72 | if parser.get_image_url(): 73 | self.image_url = parser.get_image_url() 74 | return self 75 | 76 | def set_text(self, text_and_voice): 77 | # this method name is deprecated 78 | return self.set_rich_text(rich_text=text_and_voice) 79 | 80 | def add_link(self, title, url, hide=False): 81 | self.links.append({ 82 | 'title': title, 83 | 'url': url, 84 | 'hide': hide, 85 | }) 86 | 87 | @property 88 | def has_exit_command(self) -> bool: 89 | return bool(self.commands and COMMANDS.EXIT in self.commands) 90 | -------------------------------------------------------------------------------- /dialogic/dialog/serialized_message.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | class SerializedMessage: 5 | # a base class for e.g. logging messages 6 | def __init__(self, text, user_id, from_user, timestamp=None, session_id=None, **kwargs): 7 | self.text = text 8 | self.user_id = user_id 9 | self.from_user = from_user 10 | self.timestamp = timestamp or str(datetime.utcnow()) 11 | self.session_id = session_id 12 | self.kwargs = kwargs 13 | """ 14 | Expected kwargs: 15 | text 16 | user_id 17 | message_id 18 | from_user 19 | username 20 | reply_to_id 21 | source 22 | data (original message in Alice) 23 | label (something like intent) 24 | request_id (this id the same for request and response, useful for joining logs) 25 | handler (name of the function that has produced the response) 26 | """ 27 | 28 | def to_dict(self): 29 | result = { 30 | 'text': self.text, 31 | 'user_id': self.user_id, 32 | 'from_user': self.from_user, 33 | 'timestamp': self.timestamp, 34 | 'session_id': self.session_id, 35 | } 36 | for k, v in self.kwargs.items(): 37 | if k not in result: 38 | result[k] = v 39 | return result 40 | -------------------------------------------------------------------------------- /dialogic/dialog_connector.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from typing import Optional, Dict, Any, Tuple 4 | 5 | from .storage.message_logging import BaseMessageLogger 6 | 7 | from .adapters import ( 8 | AliceAdapter, BaseAdapter, FacebookAdapter, TextAdapter, TelegramAdapter, VkAdapter, SalutAdapter 9 | ) 10 | 11 | from dialogic.storage.session_storage import BaseStorage 12 | from dialogic.dialog_manager.base import Response, Context 13 | from dialogic.dialog.names import SOURCES 14 | from dialogic.utils.content_manager import YandexImageAPI 15 | 16 | 17 | class DialogConnector: 18 | """ This class provides unified interface for Telegram and Alice, and other applications """ 19 | def __init__( 20 | self, 21 | dialog_manager, 22 | storage=None, 23 | log_storage: Optional[BaseMessageLogger] = None, 24 | default_source=SOURCES.TELEGRAM, 25 | tg_suggests_cols=1, 26 | alice_native_state=False, 27 | image_manager: Optional[YandexImageAPI] = None, 28 | adapters: Optional[Dict[str, BaseAdapter]] = None, 29 | ): 30 | """ 31 | paramaters: 32 | - alice_native_state: bool or 'user' or 'state' 33 | """ 34 | self.dialog_manager = dialog_manager 35 | self.default_source = default_source 36 | self.storage = storage or BaseStorage() 37 | self.log_storage: Optional[BaseMessageLogger] = log_storage # noqa 38 | self.tg_suggests_cols = tg_suggests_cols 39 | self.alice_native_state = alice_native_state 40 | self.image_manager = image_manager 41 | self.adapters: Dict[str, BaseAdapter] = adapters or {} 42 | self._add_default_adapters() 43 | 44 | def _add_default_adapters(self): 45 | if SOURCES.ALICE not in self.adapters: 46 | self.adapters[SOURCES.ALICE] = AliceAdapter( 47 | native_state=self.alice_native_state, 48 | image_manager=self.image_manager, 49 | ) 50 | if SOURCES.FACEBOOK not in self.adapters: 51 | self.adapters[SOURCES.FACEBOOK] = FacebookAdapter() 52 | if SOURCES.TEXT not in self.adapters: 53 | self.adapters[SOURCES.TEXT] = TextAdapter() 54 | if SOURCES.TELEGRAM not in self.adapters: 55 | self.adapters[SOURCES.TELEGRAM] = TelegramAdapter(suggest_cols=self.tg_suggests_cols) 56 | if SOURCES.VK not in self.adapters: 57 | self.adapters[SOURCES.VK] = VkAdapter() 58 | if SOURCES.SALUT not in self.adapters: 59 | self.adapters[SOURCES.SALUT] = SalutAdapter() 60 | 61 | def add_adapter(self, name: str, adapter: BaseAdapter): 62 | self.adapters[name] = adapter 63 | 64 | def respond(self, message, source=None) -> Any: 65 | ctx, resp, result = self.full_respond(message=message, source=source) 66 | return result 67 | 68 | def full_respond(self, message, source=None) -> Tuple[Context, Response, Any]: 69 | context = self.make_context(message=message, source=source) 70 | return self.respond_to_context(context=context) 71 | 72 | def respond_to_context(self, context: Context) -> Tuple[Context, Response, Any]: 73 | source = context.source 74 | adapter = self.adapters.get(source) 75 | old_user_object = copy.deepcopy(context.user_object) 76 | if adapter and self.log_storage is not None: 77 | logged = adapter.serialize_context(context=context) 78 | if logged: 79 | self.log_storage.log_data(data=logged, context=context) 80 | 81 | response = self.dialog_manager.respond(context) 82 | if response.updated_user_object is not None and response.updated_user_object != old_user_object: 83 | if adapter and adapter.uses_native_state(context=context): 84 | pass # user object is added right to the response 85 | else: 86 | self.set_user_object(context.user_id, response.updated_user_object) 87 | 88 | result = self.standardize_output(source=source, original_message=context.raw_message, response=response) 89 | if self.log_storage is not None: 90 | logged = self.adapters[source].serialize_response(data=result, context=context, response=response) 91 | if logged: 92 | self.log_storage.log_data(data=logged, context=context, response=response) 93 | return context, response, result 94 | 95 | def make_context(self, message, source=None): 96 | if source is None: 97 | source = self.default_source 98 | assert source in self.adapters, f'Source "{source}" is not in the list of dialog adapters ' \ 99 | f'{list(self.adapters.keys())}.' 100 | adapter = self.adapters[source] 101 | context = adapter.make_context(message=message) 102 | 103 | if adapter.uses_native_state(context=context): 104 | user_object = adapter.get_native_state(context=context) 105 | else: 106 | user_object = self.get_user_object(context.user_id) 107 | context.add_user_object(user_object) 108 | return context 109 | 110 | def get_user_object(self, user_id): 111 | if self.storage is None: 112 | return {} 113 | return self.storage.get(user_id) 114 | 115 | def set_user_object(self, user_id, user_object): 116 | if self.storage is None: 117 | raise NotImplementedError() 118 | self.storage.set(user_id, user_object) 119 | 120 | def standardize_output(self, source, original_message, response: Response): 121 | 122 | assert source in self.adapters, f'Source "{source}" is not in the list of dialog adapters ' \ 123 | f'{list(self.adapters.keys())}.' 124 | return self.adapters[source].make_response(response=response, original_message=original_message) 125 | 126 | def serverless_alice_handler(self, alice_request, context): 127 | """ This method can be set as a hanlder if the skill is deployed as a Yandex.Cloud Serverless Function """ 128 | return self.respond(alice_request, source=SOURCES.ALICE) 129 | -------------------------------------------------------------------------------- /dialogic/dialog_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from . import automaton, base, faq, form_filling 2 | from .base import ( 3 | Context, Response, BaseDialogManager, CascadableDialogManager, CascadeDialogManager, GreetAndHelpDialogManager, 4 | COMMANDS 5 | ) 6 | from .faq import FAQDialogManager 7 | from .form_filling import FormFillingDialogManager 8 | from .automaton import AutomatonDialogManager 9 | from .turning import TurnDialogManager 10 | -------------------------------------------------------------------------------- /dialogic/dialog_manager/base.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import typing 3 | 4 | from ..nlu import basic_nlu 5 | from ..dialog import Context, Response 6 | from ..dialog.names import COMMANDS 7 | 8 | 9 | class BaseDialogManager: 10 | """ This class defines the interface of dialog managers with a single `response` function. """ 11 | def __init__(self, default_message='I dont\'t understand.'): 12 | self.default_message = default_message 13 | 14 | def respond(self, ctx: Context): 15 | return Response(text=self.default_message) 16 | 17 | 18 | class CascadableDialogManager(BaseDialogManager): 19 | """ This interface allows dialog manager to have no answer - and to let other managers try. 20 | The `method try_to_respond` is like `respond`, but may also return None. 21 | It is expected to be used with CascadeDialogManager. 22 | """ 23 | def __init__(self, *args, **kwargs): 24 | super(CascadableDialogManager, self).__init__(*args, **kwargs) 25 | 26 | def try_to_respond(self, ctx: Context) -> typing.Union[Response, None]: 27 | """ This method should return None or a valid Response """ 28 | raise NotImplementedError() 29 | 30 | def respond(self, ctx): 31 | response = self.try_to_respond(ctx) 32 | if isinstance(response, Response): 33 | return response 34 | return Response(text=self.default_message) 35 | 36 | 37 | class CascadeDialogManager(BaseDialogManager): 38 | """ This dialog manager tries multiple dialog managers in turn, and returns the first successful response. """ 39 | def __init__(self, *managers): 40 | super(CascadeDialogManager, self).__init__() 41 | assert len(managers) > 0 42 | for manager in managers[:-1]: 43 | assert isinstance(manager, CascadableDialogManager) 44 | self.candidates = managers[:-1] 45 | assert isinstance(managers[-1], BaseDialogManager) 46 | self.final_candidate = managers[-1] 47 | 48 | def respond(self, *args, **kwargs): 49 | for manager in self.candidates: 50 | response = manager.try_to_respond(*args, **kwargs) 51 | if response is not None: 52 | return response 53 | return self.final_candidate.respond(*args, **kwargs) 54 | 55 | 56 | class GreetAndHelpDialogManager(CascadableDialogManager): 57 | """ This dialog manager can be responsible for the first and the last messages, and for the help message. """ 58 | def __init__(self, greeting_message, help_message, *args, exit_message=None, **kwargs): 59 | super(GreetAndHelpDialogManager, self).__init__(*args, **kwargs) 60 | self.greeting_message = greeting_message 61 | self.help_message = help_message 62 | self.exit_message = exit_message 63 | 64 | def try_to_respond(self, ctx: Context): 65 | if self.is_first_message(ctx): 66 | return Response(text=self.greeting_message) 67 | if self.is_like_help(ctx): 68 | return Response(text=self.help_message) 69 | if self.exit_message is not None and self.is_like_exit(ctx): 70 | return Response(text=self.exit_message, commands=[COMMANDS.EXIT]) 71 | return None 72 | 73 | def is_first_message(self, context): 74 | if not context.message_text or context.message_text == '/start': 75 | return True 76 | if basic_nlu.like_help(context.message_text): 77 | return True 78 | return False 79 | 80 | def is_like_help(self, context): 81 | return basic_nlu.like_help(context.message_text) 82 | 83 | def is_like_exit(self, context): 84 | return basic_nlu.like_exit(context.message_text) 85 | -------------------------------------------------------------------------------- /dialogic/dialog_manager/faq.py: -------------------------------------------------------------------------------- 1 | import random 2 | import yaml 3 | 4 | from collections.abc import Iterable 5 | 6 | from ..nlu import basic_nlu 7 | from ..nlu.matchers import make_matcher 8 | from .base import CascadableDialogManager, Context, Response 9 | 10 | 11 | class FAQDialogManager(CascadableDialogManager): 12 | """ This dialog manager tries to match the input message with one of the questions from its config, 13 | and if successful, gives the corresponding answer. """ 14 | def __init__(self, config, matcher='tf-idf', *args, **kwargs): 15 | super(FAQDialogManager, self).__init__(*args, **kwargs) 16 | if isinstance(config, str): 17 | with open(config, 'r', encoding='utf-8') as f: 18 | self._cfg = yaml.safe_load(f) 19 | elif isinstance(config, Iterable): 20 | self._cfg = config 21 | else: 22 | raise ValueError('Config must be a filename or a list.') 23 | if isinstance(matcher, str): 24 | matcher = make_matcher(matcher) 25 | self.matcher = matcher 26 | self._q2i = {} 27 | self._i2a = {} 28 | self._i2s = {} 29 | question_keys = [] 30 | question_labels = [] 31 | for i, pair in enumerate(self._cfg): 32 | questions = self._extract_string_or_strings(pair, key='q') 33 | for q in questions: 34 | q2 = self._normalize(q) 35 | self._q2i[q2] = i 36 | question_keys.append(q2) 37 | question_labels.append(i) 38 | self._i2a[i] = self._extract_string_or_strings(pair, key='a') 39 | self._i2s[i] = self._extract_string_or_strings(pair, key='s', allow_empty=True) 40 | self.matcher.fit(question_keys, question_labels) 41 | 42 | def try_to_respond(self, ctx: Context): 43 | text = self._normalize(ctx.message_text) 44 | index, score = self.matcher.match(text) 45 | if index is None: 46 | return None 47 | response = random.choice(self._i2a[index]) 48 | suggests = self._i2s.get(index, []) 49 | return Response(text=response, suggests=suggests, user_object=ctx.user_object).set_text(response) 50 | 51 | @staticmethod 52 | def _extract_string_or_strings(data, key, allow_empty=False): 53 | if key not in data: 54 | if allow_empty: 55 | return [] 56 | raise ValueError('The question "{}" has no "{}" key.'.format(data, key)) 57 | inputs = data[key] 58 | if isinstance(inputs, str): 59 | result = [inputs] 60 | elif isinstance(inputs, Iterable): 61 | if not all(isinstance(i, str) for i in inputs): 62 | raise ValueError('The list "{}" does not consist of strings.'.format(inputs)) 63 | result = inputs 64 | else: 65 | raise ValueError('The question "{}" is not a string or list.'.format(data)) 66 | return result 67 | 68 | def _normalize(self, text): 69 | return basic_nlu.fast_normalize(text) 70 | -------------------------------------------------------------------------------- /dialogic/dialog_manager/turning.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import yaml 4 | from typing import Union, Type, Tuple, Dict 5 | 6 | from ..nlu.regex_expander import load_intents_with_replacement 7 | from ..interfaces.yandex import extract_yandex_forms 8 | from ..nlu.regex_utils import match_forms 9 | from dialogic.cascade import DialogTurn 10 | from dialogic.dialog import Context, Response 11 | from dialogic.dialog_manager import CascadableDialogManager 12 | from dialogic.nlu import basic_nlu 13 | from dialogic.nlu.basic_nlu import fast_normalize 14 | from dialogic.nlu.matchers import TFIDFMatcher, TextNormalization, make_matcher_with_regex, AggregationMatcher 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class TurnDialogManager(CascadableDialogManager): 20 | TURN_CLS = DialogTurn 21 | 22 | def __init__( 23 | self, 24 | cascade, 25 | intents_file=None, 26 | expressions_file=None, 27 | matcher_threshold=0.8, 28 | add_basic_nlu=True, 29 | turn_cls: Type[DialogTurn] = None, 30 | reset_stage=True, 31 | **kwargs 32 | ): 33 | super(TurnDialogManager, self).__init__(**kwargs) 34 | 35 | self.cascade = cascade 36 | self.turn_cls: Type[DialogTurn] = turn_cls or self.TURN_CLS 37 | 38 | self.intents_file = intents_file 39 | self.expressions_file = expressions_file 40 | self.intents = {} 41 | self.intent_matcher: AggregationMatcher = None 42 | self.matcher_threshold = matcher_threshold 43 | self.add_basic_nlu = add_basic_nlu 44 | self.reset_stage = reset_stage 45 | 46 | if intents_file: 47 | self.load_intents(intents_file=intents_file) 48 | 49 | def load_intents(self, intents_file=None): 50 | if self.expressions_file and self.intents_file: 51 | self.intents = load_intents_with_replacement( 52 | intents_fn='texts/intents.yaml', 53 | expressions_fn='texts/expressions.yaml', 54 | ) 55 | elif self.intents_file: 56 | with open(intents_file, 'r', encoding='utf-8') as f: 57 | self.intents = yaml.safe_load(f) 58 | else: 59 | return 60 | 61 | self.intent_matcher = make_matcher_with_regex( 62 | base_matcher=TFIDFMatcher( 63 | text_normalization=TextNormalization.FAST_LEMMATIZE, threshold=self.matcher_threshold 64 | ), 65 | intents=self.intents, 66 | ) 67 | 68 | def try_to_respond(self, ctx: Context) -> Union[Response, None]: 69 | t = time.time() 70 | self.preprocess_context(ctx=ctx) 71 | text, intents, forms = self.nlu(ctx=ctx) 72 | turn = self.turn_cls( 73 | ctx=ctx, 74 | text=text, 75 | intents=intents, 76 | forms=forms, 77 | user_object=ctx.user_object or {}, 78 | ) 79 | self.preprocess_turn(turn=turn) 80 | handler_name = self.cascade(turn) 81 | logger.debug(f"Final handler: {handler_name}") 82 | response = turn.make_response() 83 | response.handler = handler_name 84 | self.postprocess_response(response=response, turn=turn) 85 | logger.debug(f'DM response took {time.time() - t} seconds') 86 | return response 87 | 88 | def normalize_text(self, ctx: Context): 89 | text = fast_normalize(ctx.message_text or '') 90 | return text 91 | 92 | def nlu(self, ctx: Context) -> Tuple[str, Dict[str, float], Dict[str, Dict]]: 93 | text = self.normalize_text(ctx=ctx) 94 | if self.intent_matcher: 95 | intents = self.intent_matcher.aggregate_scores(text) 96 | else: 97 | intents = {} 98 | forms = match_forms(text=text, intents=self.intents or {}) 99 | if ctx.yandex: 100 | ya_forms = extract_yandex_forms(ctx.yandex) 101 | forms.update(ya_forms) 102 | for intent_name in ya_forms: 103 | intents[intent_name] = 1 104 | 105 | if self.add_basic_nlu: 106 | if basic_nlu.like_help(ctx.message_text): 107 | intents['help'] = max(intents.get('help', 0), 0.9) 108 | if basic_nlu.like_yes(ctx.message_text): 109 | intents['yes'] = max(intents.get('yes', 0), 0.9) 110 | if basic_nlu.like_no(ctx.message_text): 111 | intents['no'] = max(intents.get('no', 0), 0.9) 112 | 113 | return text, intents, forms 114 | 115 | def preprocess_context(self, ctx: Context): 116 | if not ctx.user_object: 117 | ctx.add_user_object({}) 118 | if ctx.session_is_new(): 119 | if 'stage' in ctx.user_object and self.reset_stage: 120 | del ctx.user_object['stage'] 121 | ctx.user_object['sessions_count'] = ctx.user_object.get('sessions_count', 0) + 1 122 | 123 | def preprocess_turn(self, turn: DialogTurn): 124 | pass 125 | 126 | def postprocess_response(self, response: Response, turn): 127 | pass 128 | -------------------------------------------------------------------------------- /dialogic/ext/data_loading.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import io 3 | from typing import List, Dict 4 | 5 | import requests 6 | import http.client 7 | 8 | 9 | def have_internet(): 10 | """ See https://stackoverflow.com/a/29854274/6498293 """ 11 | conn = http.client.HTTPSConnection("8.8.8.8", timeout=5) 12 | try: 13 | conn.request("HEAD", "/") 14 | return True 15 | except: 16 | return False 17 | finally: 18 | conn.close() 19 | 20 | 21 | def gsheet_to_records(file_id, sheet_id): 22 | url = f'https://docs.google.com/spreadsheets/d/{file_id}/export?format=csv&id={file_id}&gid={sheet_id}' 23 | response = requests.get(url) 24 | response.encoding = response.apparent_encoding 25 | text = response.text 26 | return list(csv.DictReader(io.StringIO(text))) 27 | 28 | 29 | def rename_dicts(list_of_dicts: List[Dict], rename: Dict): 30 | for item in list_of_dicts: 31 | for old_name, new_name in rename.items(): 32 | if old_name in item: 33 | item[new_name] = item[old_name] 34 | del item[old_name] 35 | return list_of_dicts 36 | -------------------------------------------------------------------------------- /dialogic/ext/google_sheets_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Modified code from https://developers.google.com/sheets/api/quickstart/python 3 | """ 4 | import pickle 5 | import os.path 6 | 7 | from typing import List, Dict 8 | 9 | from googleapiclient.discovery import build 10 | from google_auth_oauthlib.flow import InstalledAppFlow 11 | from google.auth.transport.requests import Request 12 | 13 | 14 | # If modifying these scopes, delete the file token.pickle. 15 | 16 | 17 | SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly'] 18 | 19 | # The ID and range of a sample spreadsheet. 20 | SAMPLE_SPREADSHEET_ID = '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms' 21 | SAMPLE_RANGE_NAME = 'Class Data!A2:E' 22 | 23 | 24 | def creds_to_mongo(creds, collection, key='google_creds'): 25 | dump = pickle.dumps(creds) 26 | collection.update_one({'key': key}, {'$set': {'value': dump}}, upsert=True) 27 | 28 | 29 | def creds_from_mongo(collection, key='google_creds'): 30 | one = collection.find_one({'key': key}) 31 | if one: 32 | return pickle.loads(one['value']) 33 | 34 | 35 | def update_google_creds( 36 | creds=None, 37 | client_config=None, 38 | client_config_filename='credentials.json', 39 | dump_token=False, 40 | pickle_path='token.pickle', 41 | scopes=SCOPES, 42 | ): 43 | """ Open the login page for the user """ 44 | if not creds and os.path.exists(pickle_path): 45 | with open(pickle_path, 'rb') as token: 46 | creds = pickle.load(token) 47 | 48 | if not creds or not creds.valid: 49 | if creds and creds.expired and creds.refresh_token: 50 | creds.refresh(Request()) 51 | else: 52 | if client_config is not None: 53 | flow = InstalledAppFlow.from_client_config(client_config, scopes) 54 | else: 55 | flow = InstalledAppFlow.from_client_secrets_file(client_config_filename, scopes) 56 | creds = flow.run_local_server(port=53021) 57 | # Save the credentials for the next run 58 | if dump_token: 59 | with open(pickle_path, 'wb') as token: 60 | pickle.dump(creds, token) 61 | return creds 62 | 63 | 64 | def get_credentials(): 65 | creds = None 66 | # The file token.pickle stores the user's access and refresh tokens, and is 67 | # created automatically when the authorization flow completes for the first 68 | # time. 69 | if os.path.exists('token.pickle'): 70 | with open('token.pickle', 'rb') as token: 71 | creds = pickle.load(token) 72 | 73 | # If there are no (valid) credentials available, let the user log in. 74 | if not creds or not creds.valid: 75 | if creds and creds.expired and creds.refresh_token: 76 | creds.refresh(Request()) 77 | else: 78 | flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES) 79 | creds = flow.run_local_server(port=0) 80 | # Save the credentials for the next run 81 | with open('token.pickle', 'wb') as token: 82 | pickle.dump(creds, token) 83 | 84 | return creds 85 | 86 | 87 | def get_credentials_smart(collection): 88 | creds = creds_from_mongo(collection=collection) 89 | if not creds: 90 | print('Could not get Google credits from MongoDB') 91 | return 92 | creds = update_google_creds( 93 | creds=creds, 94 | client_config=(collection.find_one({'key': 'google_raw_creds'}) or {}).get('value'), 95 | ) 96 | return creds 97 | 98 | 99 | def load_sheet(creds=None, sheet_id=SAMPLE_SPREADSHEET_ID, range_name=SAMPLE_RANGE_NAME, rename=None) -> List[Dict]: 100 | """ Load sheet 101 | """ 102 | if creds is None: 103 | creds = get_credentials() 104 | 105 | service = build('sheets', 'v4', credentials=creds, cache_discovery=False) 106 | 107 | # Call the Sheets API 108 | sheet = service.spreadsheets() 109 | result = sheet.values().get(spreadsheetId=sheet_id, range=range_name).execute() 110 | values = result.get('values', []) 111 | 112 | headers = values[0] 113 | if rename: 114 | headers = [rename.get(v, v) for v in headers] 115 | # resize each row to make the table rectangular 116 | for row in values[1:]: 117 | if len(row) < len(headers): 118 | row.extend([None] * (len(headers) - len(row))) 119 | if len(row) > len(headers): 120 | row[:] = row[:len(headers)] 121 | # fill the data 122 | data = [] 123 | for row in values[1:]: 124 | data.append({ 125 | header: value 126 | for header, value in zip(headers, row) 127 | }) 128 | return data 129 | 130 | 131 | if __name__ == '__main__': 132 | data = load_sheet() 133 | print(len(data), 'items loaded') 134 | print(data[0]) 135 | -------------------------------------------------------------------------------- /dialogic/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | from . import vk, yandex 2 | -------------------------------------------------------------------------------- /dialogic/interfaces/yandex/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from . import request, response 4 | from .request import YandexRequest 5 | from .response import YandexResponse 6 | 7 | 8 | def extract_yandex_forms(req: YandexRequest) -> Dict[str, Dict[str, str]]: 9 | results = {} 10 | raw_forms = req and req.request and req.request.nlu and req.request.nlu.intents 11 | if not raw_forms: 12 | return results 13 | for intent_name, intent in raw_forms.items(): 14 | results[intent_name] = { 15 | slot_name: slot.value 16 | for slot_name, slot in intent.slots.items() 17 | } 18 | return results 19 | -------------------------------------------------------------------------------- /dialogic/interfaces/yandex/request.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package implements Yandex.Dialogs request protocol. 3 | The official documentation is available at 4 | https://yandex.ru/dev/dialogs/alice/doc/protocol-docpage/#request 5 | """ 6 | 7 | import attr 8 | from typing import Dict, List, Optional 9 | from dialogic.utils.serialization import FreeSerializeable, list_converter, dict_converter 10 | 11 | 12 | class ENTITY_TYPES: 13 | YANDEX_GEO = 'YANDEX.GEO' 14 | YANDEX_FIO = 'YANDEX.FIO' 15 | YANDEX_NUMBER = 'YANDEX.NUMBER' 16 | YANDEX_DATETIME = 'YANDEX.DATETIME' 17 | 18 | 19 | @attr.s 20 | class Meta(FreeSerializeable): 21 | locale: str = attr.ib() 22 | timezone: str = attr.ib() 23 | client_id: str = attr.ib() 24 | interfaces: dict = attr.ib(factory=dict) 25 | 26 | @property 27 | def has_screen(self) -> bool: 28 | return self.interfaces and 'screen' in self.interfaces 29 | 30 | @property 31 | def has_account_linking(self) -> bool: 32 | return self.interfaces and 'account_linking' in self.interfaces 33 | 34 | @property 35 | def has_audio_player(self) -> bool: 36 | return self.interfaces and 'audio_player' in self.interfaces 37 | 38 | 39 | @attr.s 40 | class Span(FreeSerializeable): 41 | start: int = attr.ib() 42 | end: int = attr.ib() 43 | 44 | 45 | @attr.s 46 | class Entity(FreeSerializeable): 47 | type: str = attr.ib() # may be one of ENTITY_TYPES, but not only 48 | tokens: Span = attr.ib(converter=Span.from_dict) 49 | value = attr.ib() 50 | 51 | 52 | @attr.s 53 | class Slot(FreeSerializeable): 54 | type: str = attr.ib() 55 | tokens: Optional[Span] = attr.ib(converter=Span.from_dict, default=None) 56 | value = attr.ib(default=None) 57 | 58 | 59 | @attr.s 60 | class Intent(FreeSerializeable): 61 | slots: Dict[str, Slot] = attr.ib(converter=dict_converter(Slot)) 62 | 63 | def get_form(self) -> Dict[str, str]: 64 | return { 65 | slot_name: slot.value 66 | for slot_name, slot in self.slots.items() 67 | } 68 | 69 | 70 | @attr.s 71 | class NLU(FreeSerializeable): 72 | tokens: List[str] = attr.ib(factory=list) 73 | entities: List[Entity] = attr.ib(converter=list_converter(Entity), factory=list) 74 | intents: Dict[str, Intent] = attr.ib(converter=dict_converter(Intent), factory=dict) 75 | 76 | def get_forms(self) -> Dict[str, Dict[str, str]]: 77 | return { 78 | intent_name: intent.get_form() 79 | for intent_name, intent in self.intents.items() 80 | } 81 | 82 | 83 | @attr.s 84 | class Request(FreeSerializeable): 85 | command: str = attr.ib(default=None) 86 | original_utterance: str = attr.ib(default=None) 87 | type: str = attr.ib(default=None) 88 | markup = attr.ib(factory=dict) 89 | payload = attr.ib(factory=dict) 90 | nlu: Optional[NLU] = attr.ib(converter=NLU.from_dict, default=None) 91 | show_type: Optional[str] = attr.ib(default=None) 92 | error: Optional[Dict] = attr.ib(default=None) 93 | 94 | 95 | class User(FreeSerializeable): 96 | user_id: str = attr.ib(default=None) 97 | access_token: str = attr.ib(default=None) 98 | 99 | 100 | class Application(FreeSerializeable): 101 | application_id: str = attr.ib(default=None) 102 | 103 | 104 | @attr.s 105 | class Location(FreeSerializeable): 106 | lat: Optional[float] = attr.ib(default=None) 107 | lon: Optional[float] = attr.ib(default=None) 108 | accuracy: Optional[float] = attr.ib(default=None) 109 | 110 | 111 | @attr.s 112 | class Session(FreeSerializeable): 113 | message_id: int = attr.ib() 114 | session_id: str = attr.ib() 115 | skill_id: str = attr.ib() 116 | # user_id is deprecated, use application.application_id or user.user_id instead 117 | user_id: str = attr.ib() 118 | user: Optional[User] = attr.ib(default=None) 119 | application: Optional[Application] = attr.ib(default=None) 120 | new: bool = attr.ib(default=False) 121 | location: Optional[Location] = attr.ib(default=None, converter=Location.from_dict) 122 | 123 | 124 | @attr.s 125 | class State(FreeSerializeable): 126 | session: Optional[Dict] = attr.ib(default=None) 127 | user: Optional[Dict] = attr.ib(default=None) 128 | application: Optional[Dict] = attr.ib(default=None) 129 | audio_player: Optional[Dict] = attr.ib(default=None) 130 | 131 | 132 | @attr.s 133 | class YandexRequest(FreeSerializeable): 134 | meta: Meta = attr.ib(converter=Meta.from_dict) 135 | request: Request = attr.ib(converter=Request.from_dict) 136 | session: Optional[Session] = attr.ib(converter=Session.from_dict) 137 | new: bool = attr.ib(default=False) 138 | version: str = attr.ib(default='1.0') 139 | state: Optional[State] = attr.ib(converter=State.from_dict, default=None) 140 | -------------------------------------------------------------------------------- /dialogic/interfaces/yandex/response.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package implements Yandex.Dialogs response protocol. 3 | The official documentation is available at 4 | https://yandex.ru/dev/dialogs/alice/doc/protocol-docpage/#response 5 | """ 6 | 7 | import attr 8 | import copy 9 | 10 | from typing import Union, Dict, List, Optional 11 | from dialogic.utils.serialization import Serializeable, list_converter 12 | 13 | 14 | class CARD_TYPES: 15 | BIG_IMAGE = 'BigImage' 16 | ITEMS_LIST = 'ItemsList' 17 | IMAGE_GALLERY = 'ImageGallery' 18 | 19 | 20 | @attr.s 21 | class Button(Serializeable): 22 | title: str = attr.ib() 23 | payload = attr.ib() 24 | url: str = attr.ib() 25 | hide: bool = attr.ib() 26 | 27 | 28 | @attr.s 29 | class Card(Serializeable): 30 | type: str = attr.ib() 31 | 32 | 33 | @attr.s 34 | class CardButton(Serializeable): 35 | text: str = attr.ib(default=None) 36 | url: str = attr.ib(default=None) 37 | payload = attr.ib(default=None) 38 | 39 | 40 | @attr.s 41 | class BigImage(Card): 42 | type: str = attr.ib(default=CARD_TYPES.BIG_IMAGE, init=False) 43 | image_id: str = attr.ib() 44 | title: str = attr.ib(default=None) 45 | description: str = attr.ib(default=None) 46 | button: CardButton = attr.ib(default=None, converter=CardButton.from_dict) 47 | 48 | 49 | @attr.s 50 | class ItemsListHeader(Serializeable): 51 | text: str = attr.ib() 52 | 53 | 54 | @attr.s 55 | class ItemsListFooter(Serializeable): 56 | text: str = attr.ib() 57 | button: Optional[CardButton] = attr.ib(default=None, converter=CardButton.from_dict) 58 | 59 | 60 | @attr.s 61 | class ItemsListItem(Serializeable): 62 | image_id: str = attr.ib(default=None) 63 | title: str = attr.ib(default=None) 64 | description: str = attr.ib(default=None) 65 | button: CardButton = attr.ib(default=None, converter=CardButton.from_dict) 66 | 67 | 68 | @attr.s 69 | class ItemsList(Card): 70 | type: str = attr.ib(default=CARD_TYPES.ITEMS_LIST, init=False) 71 | header: Optional[ItemsListHeader] = attr.ib(converter=ItemsListHeader.from_dict, default=None) 72 | items: List[ItemsListItem] = attr.ib(converter=list_converter(ItemsListItem), factory=list) 73 | footer: Optional[ItemsListFooter] = attr.ib(converter=ItemsListFooter.from_dict, default=None) 74 | 75 | 76 | @attr.s 77 | class ImageGalleryItem(Serializeable): 78 | image_id: str = attr.ib(default=None) # 328 x 480 79 | title: str = attr.ib(default=None) 80 | button: Optional[CardButton] = attr.ib(default=None, converter=CardButton.from_dict) 81 | 82 | 83 | @attr.s 84 | class ImageGallery(Card): 85 | type: str = attr.ib(default=CARD_TYPES.IMAGE_GALLERY, init=False) 86 | items: List[ImageGalleryItem] = attr.ib(converter=list_converter(ImageGalleryItem), factory=list) # 1 to 10 items 87 | 88 | 89 | @attr.s 90 | class ShowItemMeta(Serializeable): 91 | content_id: Optional[str] = attr.ib(default=None) 92 | title: Optional[str] = attr.ib(default=None) 93 | title_tts: Optional[str] = attr.ib(default=None) 94 | publication_date: Optional[str] = attr.ib(default=None) # like "2020-12-03T10:39:32.195044179Z" 95 | expiration_date: Optional[str] = attr.ib(default=None) 96 | 97 | 98 | def card_converter(data): 99 | if not data: 100 | return None 101 | if isinstance(data, Card): 102 | return data 103 | new_data = copy.deepcopy(data) 104 | card_type = new_data['type'] 105 | del new_data['type'] 106 | 107 | if card_type == CARD_TYPES.BIG_IMAGE: 108 | return BigImage.from_dict(new_data) 109 | if card_type == CARD_TYPES.ITEMS_LIST: 110 | return ItemsList.from_dict(new_data) 111 | if card_type == CARD_TYPES.IMAGE_GALLERY: 112 | return ImageGallery.from_dict(new_data) 113 | 114 | 115 | @attr.s 116 | class Response(Serializeable): 117 | text: str = attr.ib() 118 | tts: str = attr.ib(default=None) 119 | buttons: List[Button] = attr.ib(converter=list_converter(Button), factory=list) 120 | card: Optional[Union[BigImage, ItemsList, ImageGallery]] = attr.ib(converter=card_converter, default=None) 121 | end_session: bool = attr.ib(default=False) 122 | show_item_meta: Optional[ShowItemMeta] = attr.ib(default=None, converter=ShowItemMeta.from_dict) 123 | directives: Optional[Dict] = attr.ib(default=None) 124 | 125 | 126 | @attr.s 127 | class YandexResponse(Serializeable): 128 | response: Response = attr.ib(converter=Response.from_dict) 129 | session_state: Optional[Dict] = attr.ib(default=None) 130 | user_state_update: Optional[Dict] = attr.ib(default=None) 131 | session = attr.ib(default=None) 132 | version: str = attr.ib(default='1.0') 133 | -------------------------------------------------------------------------------- /dialogic/interfaces/yandex/utils.py: -------------------------------------------------------------------------------- 1 | from .cascade import DialogTurn 2 | 3 | 4 | def is_morning_show(turn: DialogTurn) -> bool: 5 | if not turn.ctx.yandex or not turn.ctx.yandex.request: 6 | return False 7 | r = turn.ctx.yandex.request 8 | if r.type != 'Show.Pull': 9 | return False 10 | return r.show_type == 'MORNING' 11 | -------------------------------------------------------------------------------- /dialogic/nlg/__init__.py: -------------------------------------------------------------------------------- 1 | from . import controls, reply_markup, sampling, morph 2 | -------------------------------------------------------------------------------- /dialogic/nlg/controls.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | from collections.abc import Iterable, Mapping 5 | from collections import OrderedDict 6 | 7 | 8 | def make_button(button=None, text=None, url=None, payload=None): 9 | if isinstance(button, GalleryButton): 10 | return button 11 | elif isinstance(button, Mapping): 12 | return GalleryButton(**button) 13 | elif text is not None or url is not None or payload is not None: 14 | return GalleryButton(text=text, url=url, payload=payload) 15 | else: 16 | return None 17 | 18 | 19 | class Button: 20 | def __init__(self, title=None, payload=None, url=None, hide=True): 21 | # todo: write getters and setters with validation 22 | self.title = title 23 | if self.title is None: 24 | raise ValueError('Button title cannot be empty') 25 | else: 26 | assert len(self.title) <= 64, 'Button title cannot be longer than 64 symbols' 27 | self.payload = payload 28 | if self.payload is not None: 29 | assert sys.getsizeof(json.dumps(self.payload)) <= 4096, 'Size of button payload cannot exceed 4096 bytes' 30 | self.url = url 31 | if self.url is not None: 32 | assert sys.getsizeof(self.url) <= 1024, 'Size of button URL cannot exceed 1024 bytes' 33 | self.hide = bool(hide) 34 | 35 | def to_dict(self): 36 | result = OrderedDict() 37 | if self.title is not None: 38 | result['title'] = self.title 39 | if self.payload is not None: 40 | result['payload'] = self.payload 41 | if self.url is not None: 42 | result['url'] = self.url 43 | result['hide'] = self.hide 44 | return result 45 | 46 | 47 | class BigImage: 48 | def __init__(self, image_id=None, title=None, description=None, 49 | button=None, button_text=None, button_url=None, button_payload=None): 50 | self.image_id = image_id 51 | self.title = title 52 | self.description = description 53 | if self.description is not None: 54 | assert len(self.description) <= 256, 'Big Image description cannot be longer than 256 symbols' 55 | self.button = make_button(button, button_text, button_url, button_payload) 56 | 57 | def to_dict(self): 58 | result = OrderedDict() 59 | result['type'] = 'BigImage' 60 | result['image_id'] = self.image_id 61 | if self.title is not None: 62 | result['title'] = self.title 63 | if self.description is not None: 64 | result['description'] = self.description 65 | if self.button is not None: 66 | result['button'] = self.button.to_dict() 67 | return result 68 | 69 | 70 | class GalleryButton: 71 | def __init__(self, text=None, payload=None, url=None): 72 | # todo: write getters and setters with validation 73 | self.text = text 74 | if self.text is not None: 75 | assert len(self.text) <= 64, 'Button title cannot be longer than 64 symbols' 76 | self.payload = payload 77 | if self.payload is not None: 78 | assert sys.getsizeof(json.dumps(self.payload)) <= 4096, 'Size of button payload cannot exceed 4096 bytes' 79 | self.url = url 80 | if self.url is not None: 81 | assert sys.getsizeof(self.url) <= 1024, 'Size of button URL cannot exceed 1024 bytes' 82 | 83 | def to_dict(self): 84 | result = OrderedDict() 85 | if self.text is not None: 86 | result['text'] = self.text 87 | if self.payload is not None: 88 | result['payload'] = self.payload 89 | if self.url is not None: 90 | result['url'] = self.url 91 | return result 92 | 93 | 94 | class GalleryItem: 95 | def __init__(self, image_id=None, title=None, description=None, 96 | button=None, button_text=None, button_url=None, button_payload=None): 97 | self.image_id = image_id 98 | self.title = title 99 | self.description = description 100 | if self.description is not None: 101 | assert len(self.description) <= 256, 'Gallery item description cannot be longer than 256 symbols' 102 | self.button = make_button(button, button_text, button_url, button_payload) 103 | 104 | def to_dict(self): 105 | result = OrderedDict() 106 | if self.image_id is not None: 107 | result['image_id'] = self.image_id 108 | if self.title is not None: 109 | result['title'] = self.title 110 | if self.description is not None: 111 | result['description'] = self.description 112 | if self.button is not None: 113 | result['button'] = self.button.to_dict() 114 | return result 115 | 116 | 117 | class GalleryFooter: 118 | def __init__(self, text=None, 119 | button=None, button_text=None, button_url=None, button_payload=None): 120 | self.text = text 121 | if self.text is not None: 122 | assert len(self.text) <= 64, 'Gallery footer text cannot be longer than 64 symbols' 123 | self.button = make_button(button, button_text, button_url, button_payload) 124 | 125 | def to_dict(self): 126 | result = OrderedDict() 127 | if self.text is not None: 128 | result['text'] = self.text 129 | if self.button is not None: 130 | result['button'] = self.button.to_dict() 131 | return result 132 | 133 | 134 | class Gallery: 135 | def __init__(self, title=None, items=None, footer=None): 136 | self.title = title 137 | if self.title is not None: 138 | assert len(self.title) <= 64, 'Gallery header text cannot be longer than 64 symbols' 139 | if not isinstance(items, Iterable): 140 | raise ValueError('Gallery items should be an Iterable, got {}'.format(type(items))) 141 | if items is None: 142 | items = [] 143 | if len(items) < 1 or len(items) > 5: 144 | raise ValueError('Gallery should contain 1-5 items, got {}'.format(len(items))) 145 | self.items = items 146 | if footer is not None: 147 | assert isinstance(footer, GalleryFooter) 148 | # todo: make footer create-able from config 149 | self.footer = footer 150 | 151 | def to_dict(self): 152 | result = OrderedDict() 153 | result['type'] = 'ItemsList' 154 | if self.title is not None: 155 | result['header'] = {'text': self.title} 156 | result['items'] = [item.to_dict() for item in self.items] 157 | if self.footer is not None: 158 | result['footer'] = self.footer.to_dict() 159 | return result 160 | 161 | 162 | class ImageGallery: 163 | def __init__(self, items=None): 164 | if not isinstance(items, Iterable): 165 | raise ValueError('ImageGallery items should be an Iterable, got {}'.format(type(items))) 166 | if items is None: 167 | items = [] 168 | if len(items) < 1 or len(items) > 10: 169 | raise ValueError('ImageGallery should contain 1-10 items, got {}'.format(len(items))) 170 | self.items = items 171 | 172 | def to_dict(self): 173 | result = OrderedDict() 174 | result['type'] = 'ImageGallery' 175 | result['items'] = [item.to_dict() for item in self.items] 176 | return result 177 | -------------------------------------------------------------------------------- /dialogic/nlg/morph.py: -------------------------------------------------------------------------------- 1 | from dialogic.nlu.basic_nlu import PYMORPHY 2 | 3 | 4 | def with_number(noun, number): 5 | text = agree_with_number(noun=noun, number=number) 6 | return f'{number} {text}' 7 | 8 | 9 | def agree_with_number(noun, number): 10 | last = abs(number) % 10 11 | tens = abs(number) % 100 // 10 12 | if PYMORPHY: 13 | parses = PYMORPHY.parse(noun) 14 | if parses: 15 | return parses[0].make_agree_with_number(abs(number)).word 16 | # detect conjugation based on the word ending 17 | if last == 1: 18 | return noun 19 | elif noun.endswith('ка'): 20 | if last in {2, 3, 4}: 21 | return noun[:-1] + 'и' 22 | else: 23 | return noun[:-1] + 'ек' 24 | elif noun.endswith('а'): 25 | if last in {2, 3, 4}: 26 | return noun[:-1] + 'ы' 27 | else: 28 | return noun[:-1] 29 | else: 30 | if last in {2, 3, 4}: 31 | return noun + 'а' 32 | else: 33 | return noun + 'ов' 34 | 35 | 36 | def inflect_case(text, case): 37 | if PYMORPHY: 38 | res = [] 39 | for word in text.split(): 40 | parses = PYMORPHY.parse(word) 41 | word_infl = None 42 | if parses: 43 | inflected = parses[0].inflect({case}) 44 | if inflected: 45 | word_infl = inflected.word 46 | res.append(word_infl or word) 47 | return ' '.join(res) 48 | return text 49 | 50 | 51 | def human_duration(hours=0, minutes=0, seconds=0): 52 | total = hours * 3600 + minutes * 60 + seconds 53 | s = total % 60 54 | m = (total // 60) % 60 55 | h = total // 3600 56 | parts = [] 57 | if h: 58 | parts.append(with_number('час', h)) 59 | if m: 60 | parts.append(with_number('минута', m)) 61 | if s or not h and not m: 62 | parts.append(with_number('секунда', s)) 63 | return ' '.join(parts) 64 | -------------------------------------------------------------------------------- /dialogic/nlg/reply_markup.py: -------------------------------------------------------------------------------- 1 | import distutils.util 2 | import warnings 3 | 4 | from html.parser import HTMLParser 5 | 6 | 7 | class TTSParser(HTMLParser): 8 | """ 9 | Allows parsing texts like 10 | 'I study in the 1first grade. The proof' 11 | """ 12 | TAG_TEXT = 'text' 13 | TAG_VOICE = 'voice' 14 | TAG_LINK = 'a' 15 | TAG_SPEAKER = 'speaker' # it has no close tag 16 | TAG_IMG = 'img' 17 | SUPPORTED_TAGS = {TAG_TEXT, TAG_VOICE, TAG_LINK, TAG_SPEAKER, TAG_IMG} 18 | 19 | def __init__(self): 20 | super(TTSParser, self).__init__() 21 | self._text = '' 22 | self._voice = '' 23 | self._current_tag = None 24 | self._links = [] 25 | self._image_id = None 26 | self._image_url = None 27 | 28 | def handle_starttag(self, tag, attrs): 29 | if self._current_tag is not None: 30 | raise ValueError('Open tag "{}" encountered, but tag "{}" is not closed'.format(self._current_tag, tag)) 31 | attrs_dict = dict(attrs) if attrs else {} 32 | if tag not in self.SUPPORTED_TAGS: 33 | warnings.warn('Encountered an unknown tag "{}", will ignore it'.format(tag)) 34 | if tag == self.TAG_SPEAKER: 35 | self._voice += self.get_starttag_text() 36 | return # speaker tag cannot be current 37 | self._current_tag = tag 38 | if tag == self.TAG_LINK: 39 | if 'href' not in attrs_dict: 40 | raise ValueError('The "a" tag has no "href" attribute; attrs: "{}".'.format(attrs)) 41 | link = {'url': attrs_dict['href'], 'title': ''} 42 | if 'hide' in attrs_dict: 43 | link['hide'] = bool(distutils.util.strtobool(attrs_dict['hide'])) 44 | self._links.append(link) 45 | if tag == self.TAG_IMG: 46 | self._image_id = attrs_dict.get('id') 47 | self._image_url = attrs_dict.get('src') 48 | 49 | def handle_endtag(self, tag): 50 | if self._current_tag is None: 51 | raise ValueError('Encountered close tag "{}", but there are no open tags'.format(tag)) 52 | if tag == self.TAG_LINK: 53 | if self._links[-1]['title'].strip() == '': 54 | raise ValueError('The "a" tag has emtpy contents') 55 | self._current_tag = None 56 | 57 | def handle_data(self, data): 58 | if self._current_tag is None or self._current_tag == self.TAG_TEXT: 59 | self._text += data 60 | if self._current_tag is None or self._current_tag == self.TAG_VOICE: 61 | self._voice += data 62 | if self._current_tag == self.TAG_LINK: 63 | self._links[-1]['title'] += data 64 | 65 | def get_text(self): 66 | return self._text 67 | 68 | def get_voice(self): 69 | return self._voice 70 | 71 | def get_links(self): 72 | return self._links 73 | 74 | def get_image_id(self): 75 | return self._image_id 76 | 77 | def get_image_url(self): 78 | return self._image_url 79 | -------------------------------------------------------------------------------- /dialogic/nlg/sampling.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | 4 | _RANDOM: bool = True 5 | 6 | 7 | def random_on(): 8 | global _RANDOM 9 | _RANDOM = True 10 | 11 | 12 | def random_off(): 13 | global _RANDOM 14 | _RANDOM = False 15 | 16 | 17 | def sample(pattern, rnd=None, sep='|', drop_proba=0.5): 18 | """ Sample various texts from patterns such as '{Okay|well}{ dude}*, when should we {begin|start}?' 19 | Group of options are enclosed by '{}' brackets and separated by `sep`. Nested groups are not supported. 20 | The groups marked by '*' star are omitted with probability `drop_proba`. 21 | If `rnd` is False, the first option is always returned, which may simplify testing or debugging. 22 | """ 23 | if rnd is None: 24 | rnd = _RANDOM 25 | 26 | def f(x): 27 | options = x.group(1).split(sep) 28 | if not options: 29 | return '' 30 | if not rnd: 31 | return options[0] 32 | if drop_proba == 1: 33 | return '' 34 | elif drop_proba == 0: 35 | pass 36 | elif x.group().endswith('*') and random.random() > drop_proba: 37 | return '' 38 | return random.choice(options) 39 | 40 | return re.sub('{([^}]*?)}\\*?', f, pattern) 41 | -------------------------------------------------------------------------------- /dialogic/nlu/__init__.py: -------------------------------------------------------------------------------- 1 | from . import basic_nlu, matchers, regex_expander 2 | -------------------------------------------------------------------------------- /dialogic/nlu/basic_nlu.py: -------------------------------------------------------------------------------- 1 | import pymorphy2 2 | import re 3 | 4 | from functools import lru_cache 5 | 6 | PYMORPHY = pymorphy2.MorphAnalyzer() 7 | 8 | 9 | @lru_cache(maxsize=16384) 10 | def word2lemma(word): 11 | hypotheses = PYMORPHY.parse(word) 12 | if len(hypotheses) == 0: 13 | return word 14 | return hypotheses[0].normal_form 15 | 16 | 17 | def fast_normalize(text, lemmatize=False): 18 | text = re.sub('[^a-zа-яё0-9]+', ' ', text.lower()) 19 | # todo: preserve floats 20 | # we consider '-' as a delimiter, because it is often missing in results of ASR 21 | text = re.sub('\\s+', ' ', text).strip() 22 | if lemmatize: 23 | text = ' '.join([word2lemma(w) for w in text.split()]) 24 | text = re.sub('ё', 'е', text) 25 | return text 26 | 27 | 28 | def like_help(text): 29 | text = fast_normalize(text) 30 | return bool(re.match('^(алиса |яндекс )?(помощь|что ты (умеешь|можешь))$', text)) 31 | 32 | 33 | def like_exit(text): 34 | text = fast_normalize(text) 35 | return bool(re.match('^(алиса |яндекс )?(выход|хватит( болтать| играть)?|выйти|закончить)$', text)) 36 | 37 | 38 | def like_yes(text): 39 | text = fast_normalize(text) 40 | return bool(re.match('^(да|ага|окей|ок|конечно|yes|yep|хорошо|ладно)$', text)) 41 | 42 | 43 | def like_no(text): 44 | text = fast_normalize(text) 45 | return bool(re.match('^(нет|не|no|nope)$', text)) 46 | -------------------------------------------------------------------------------- /dialogic/nlu/regex_expander.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module allows creating reusable parts of regular expressions. 3 | It might be useful for matching intents or extracting slots. 4 | The resulting language is not as powerful as grammars, but close to it. 5 | 6 | The example below consists of two data files and a code file: 7 | The file `expressions.yaml`: 8 | ``` 9 | NUMBER: '[0-9]+' 10 | _NUMBER_GROUP: '(number )?(?P{{NUMBER}})' 11 | ``` 12 | The file `intents.yaml`: 13 | ``` 14 | choose: 15 | regex: '((choose|take|set) )?{{_NUMBER_GROUP}}' 16 | ``` 17 | The file `code.py`: 18 | ``` 19 | intents = load_intents_with_replacement('intents.yaml', 'expressions.yaml') 20 | matcher = make_matcher_with_regex(TFIDFMatcher(), intents=intents) 21 | print(matcher.match('take number 10')) 22 | """ 23 | 24 | import re 25 | import yaml 26 | 27 | 28 | def _patch(text, expressions): 29 | def sub_maker(match): 30 | return '({})'.format(expressions[match.group(1)]) 31 | return re.sub('{{\\s*([a-zA-Z_]+)\\s*}}', sub_maker, text) 32 | 33 | 34 | def load_expressions(filename): 35 | with open(filename, encoding='utf-8') as f: 36 | expressions = yaml.safe_load(f) 37 | for k in list(expressions.keys()): 38 | expressions[k] = _patch(expressions[k], expressions) 39 | return expressions 40 | 41 | 42 | def load_intents_with_replacement(intents_fn, expressions_fn): 43 | expressions = load_expressions(expressions_fn) 44 | with open(intents_fn, encoding='utf-8') as f: 45 | intents = yaml.safe_load(f) 46 | for v in intents.values(): 47 | if 'regexp' in v: 48 | if isinstance(v['regexp'], list): 49 | v['regexp'] = [_patch(e, expressions) for e in v['regexp']] 50 | elif isinstance(v['regexp'], str): 51 | v['regexp'] = _patch(v['regexp'], expressions) 52 | return intents 53 | -------------------------------------------------------------------------------- /dialogic/nlu/regex_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict 3 | 4 | try: 5 | import regex 6 | except ImportError: 7 | regex = None 8 | 9 | regex_or_re = regex or re 10 | 11 | 12 | def drop_none(d): 13 | return {k: v for k, v in d.items() if v is not None} 14 | 15 | 16 | def match_forms(text: str, intents: dict) -> Dict[str, Dict]: 17 | forms = {} 18 | for intent_name, intent_value in intents.items(): 19 | if 'regexp' in intent_value: 20 | expressions = intent_value['regexp'] 21 | if isinstance(expressions, str): 22 | expressions = [expressions] 23 | for exp in expressions: 24 | match = regex_or_re.match(exp, text) 25 | if match: 26 | forms[intent_name] = drop_none(match.groupdict()) 27 | break 28 | return forms 29 | -------------------------------------------------------------------------------- /dialogic/server/__init__.py: -------------------------------------------------------------------------------- 1 | from . import flask_ngrok, flask_server 2 | -------------------------------------------------------------------------------- /dialogic/server/flask_ngrok.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module has been copied from https://github.com/gstaff/flask-ngrok with a few small modification 3 | """ 4 | 5 | import atexit 6 | import json 7 | import os 8 | import platform 9 | import shutil 10 | import subprocess 11 | import tempfile 12 | import time 13 | import zipfile 14 | from pathlib import Path 15 | from threading import Timer 16 | 17 | import requests 18 | 19 | 20 | def _get_command(): 21 | system = platform.system() 22 | if system == "Darwin": 23 | command = "ngrok" 24 | elif system == "Windows": 25 | command = "ngrok.exe" 26 | elif system == "Linux": 27 | command = "ngrok" 28 | else: 29 | raise Exception("{system} is not supported".format(system=system)) 30 | return command 31 | 32 | 33 | def run_ngrok(port, wait=5, retries=10): 34 | command = _get_command() 35 | ngrok_path = str(Path(tempfile.gettempdir(), "ngrok")) 36 | _download_ngrok(ngrok_path) 37 | executable = str(Path(ngrok_path, command)) 38 | os.chmod(executable, 0o777) 39 | ngrok = subprocess.Popen([executable, 'http', str(port)]) 40 | atexit.register(ngrok.terminate) 41 | localhost_url = "http://localhost:4040/api/tunnels" # Url with tunnel details 42 | time.sleep(1) 43 | tunnel_url = None 44 | 45 | for i in range(retries): 46 | try: 47 | tunnel_url = requests.get(localhost_url).text # Get the tunnel information 48 | break 49 | except requests.exceptions.ConnectionError as e: 50 | print('Will retry connecting to ngrok api, got error {}'.format(e)) 51 | time.sleep(wait) 52 | if tunnel_url is None: 53 | raise ValueError('Could not connect to ngrok api, exiting') 54 | j = json.loads(tunnel_url) 55 | 56 | tunnel_url = j['tunnels'][0]['public_url'] # Do the parsing of the get 57 | tunnel_url = tunnel_url.replace("https", "http") 58 | return tunnel_url 59 | 60 | 61 | def _download_ngrok(ngrok_path): 62 | if Path(ngrok_path).exists(): 63 | return 64 | system = platform.system() 65 | if system == "Darwin": 66 | url = "https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-darwin-amd64.zip" 67 | elif system == "Windows": 68 | url = "https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-windows-amd64.zip" 69 | elif system == "Linux": 70 | url = "https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-amd64.zip" 71 | else: 72 | raise Exception("{} is not supported".format(system)) 73 | download_path = _download_file(url) 74 | with zipfile.ZipFile(download_path, "r") as zip_ref: 75 | zip_ref.extractall(ngrok_path) 76 | 77 | 78 | def _download_file(url): 79 | local_filename = url.split('/')[-1] 80 | r = requests.get(url, stream=True) 81 | download_path = str(Path(tempfile.gettempdir(), local_filename)) 82 | with open(download_path, 'wb') as f: 83 | shutil.copyfileobj(r.raw, f) 84 | return download_path 85 | 86 | 87 | def start_ngrok(port): 88 | ngrok_address = run_ngrok(port) 89 | print(" * Running on {}".format(ngrok_address)) 90 | print(" * Traffic stats available on http://127.0.0.1:4040") 91 | 92 | 93 | def run_with_ngrok(app): 94 | """ 95 | The provided Flask app will be securely exposed to the public internet via ngrok when run, 96 | and the its ngrok address will be printed to stdout 97 | :param app: a Flask application object 98 | :return: None 99 | """ 100 | old_run = app.run 101 | 102 | def new_run(*args, **kwargs): 103 | port = kwargs.get('port', 5000) 104 | thread = Timer(1, start_ngrok, args=(port,)) 105 | thread.setDaemon(True) 106 | thread.start() 107 | old_run(*args, **kwargs) 108 | app.run = new_run 109 | -------------------------------------------------------------------------------- /dialogic/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from . import database_utils, message_logging, session_storage 2 | -------------------------------------------------------------------------------- /dialogic/storage/database_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from collections.abc import Mapping 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def get_mongo_or_mock(mongodb_uri=None, mongodb_uri_env_name='MONGODB_URI'): 10 | if mongodb_uri is None: 11 | mongodb_uri = os.environ.get(mongodb_uri_env_name) 12 | if mongodb_uri: 13 | from pymongo import MongoClient 14 | mongo_client = MongoClient(mongodb_uri) 15 | mongo_db = mongo_client.get_default_database() 16 | else: 17 | logging.warning('Did not found mongodb uri, trying to load mongomock instead. ' 18 | 'This storage is not persistable and may cause problems in production; ' 19 | 'please use it for testing only.') 20 | import mongomock 21 | mongo_client = mongomock.MongoClient() 22 | mongo_db = mongo_client.db 23 | return mongo_db 24 | 25 | 26 | def get_boto_s3( 27 | service_name='s3', 28 | endpoint_url='https://storage.yandexcloud.net', 29 | aws_access_key_id=None, 30 | aws_access_key_id_env_name='AWS_ACCESS_KEY_ID', 31 | aws_secret_access_key=None, 32 | aws_secret_access_key_env_name='AWS_SECRET_ACCESS_KEY', 33 | region_name='ru-central1', 34 | ): 35 | import boto3 36 | session = boto3.session.Session() 37 | s3 = session.client( 38 | service_name=service_name, 39 | endpoint_url=endpoint_url, 40 | aws_access_key_id=aws_access_key_id or os.environ[aws_access_key_id_env_name], 41 | aws_secret_access_key=aws_secret_access_key or os.environ[aws_secret_access_key_env_name], 42 | region_name=region_name, 43 | ) 44 | return s3 45 | 46 | 47 | def fix_bson_keys(data, dot_symbol='~'): 48 | """ Replace dots in dict keys with other symbols, to comply with Pymongo checks """ 49 | if isinstance(data, Mapping): 50 | result = {} 51 | for key, value in data.items(): 52 | new_key = key 53 | if isinstance(key, str): 54 | if '.' in key: 55 | logger.warning('Replacing a dot in key {} with {}'.format(key, dot_symbol)) 56 | new_key = new_key.replace('.', dot_symbol) 57 | else: 58 | logger.warning('Replacing a key {} of type {} with its string representation'.format(key, type(key))) 59 | new_key = str(key) 60 | result[new_key] = fix_bson_keys(value, dot_symbol=dot_symbol) 61 | return result 62 | elif isinstance(data, list): 63 | return [fix_bson_keys(item, dot_symbol=dot_symbol) for item in data] 64 | else: 65 | return data 66 | -------------------------------------------------------------------------------- /dialogic/storage/message_logging.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | 4 | from datetime import datetime 5 | 6 | from ..dialog.serialized_message import SerializedMessage 7 | from dialogic.dialog import Context, Response 8 | from dialogic.dialog.names import SOURCES, REQUEST_TYPES 9 | from dialogic.storage.database_utils import get_mongo_or_mock, fix_bson_keys 10 | 11 | 12 | try: 13 | import pymongo 14 | except ModuleNotFoundError: 15 | pymongo = None 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class BaseMessageLogger: 22 | def __init__(self, detect_pings=False, not_log_id=None, ignore_show=True): 23 | self.detect_pings = detect_pings 24 | self.not_log_id = not_log_id or set() 25 | self.ignore_show = ignore_show 26 | 27 | def log_context(self, context, **kwargs): 28 | raise DeprecationWarning( 29 | 'This operation is no longer supported - please, use log_data method.' 30 | ) 31 | 32 | def log_response(self, data, source, context=None, response=None, **kwargs): 33 | raise DeprecationWarning( 34 | 'This operation is no longer supported - please, use log_data method.' 35 | ) 36 | 37 | def log_message(self, message, source, context=None, response=None, **kwargs): 38 | raise DeprecationWarning( 39 | 'This operation is no longer supported - please, use log_data method.' 40 | ) 41 | 42 | def log_data( 43 | self, 44 | data: SerializedMessage, 45 | context: Context = None, 46 | response: Response = None, 47 | **kwargs 48 | ): 49 | if not data: 50 | return 51 | if response is not None and response.label is not None: 52 | data.kwargs['label'] = response.label 53 | if response is not None and response.handler is not None: 54 | data.kwargs['handler'] = response.handler 55 | if self.should_ignore_message(result=data, context=context, response=response): 56 | return 57 | self.save_a_message(data.to_dict()) 58 | 59 | def is_like_ping(self, context=None): 60 | return context is not None and context.source == SOURCES.ALICE \ 61 | and context.message_text == 'ping' and context.session_is_new() 62 | 63 | def should_ignore_message( 64 | self, result: SerializedMessage, context: Context = None, response: Response = None 65 | ) -> bool: 66 | if self.not_log_id is not None and result.user_id in self.not_log_id: 67 | # main reason: don't log pings from Yandex 68 | return True 69 | if self.detect_pings and self.is_like_ping(context): 70 | return True 71 | if self.ignore_show: 72 | if context.yandex and context.yandex.request and context.yandex.request.type == REQUEST_TYPES.SHOW_PULL: 73 | return True 74 | return False 75 | 76 | def save_a_message(self, message_dict): 77 | logger.warning('You are using a BaseMessageLogger that does not store messages. ' 78 | 'Please extend it to save logs directly to a database.') 79 | logger.info(message_dict) 80 | 81 | 82 | class MongoMessageLogger(BaseMessageLogger): 83 | def __init__(self, collection=None, database=None, collection_name='message_logs', write_concern=0, **kwargs): 84 | super(MongoMessageLogger, self).__init__(**kwargs) 85 | self.collection = collection 86 | if self.collection is None: 87 | if database is None: 88 | database = get_mongo_or_mock() 89 | if pymongo and not isinstance(write_concern, pymongo.write_concern.WriteConcern): 90 | write_concern = pymongo.write_concern.WriteConcern(w=write_concern) 91 | self.collection = database.get_collection(collection_name, write_concern=write_concern) 92 | 93 | def save_a_message(self, message_dict): 94 | self.collection.insert_one(fix_bson_keys(message_dict)) 95 | -------------------------------------------------------------------------------- /dialogic/storage/session_storage.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import copy 3 | import json 4 | import os 5 | 6 | from dialogic.storage.database_utils import fix_bson_keys 7 | from dialogic.utils import database_utils 8 | 9 | 10 | class BaseStorage: 11 | def __init__(self): 12 | self.dict = {} 13 | 14 | def get(self, key): 15 | return copy.deepcopy(self.dict.get(key, {})) 16 | 17 | def set(self, key, value): 18 | self.dict[key] = copy.deepcopy(value) 19 | 20 | 21 | class FileBasedStorage(BaseStorage): 22 | def __init__(self, path='session_storage', multifile=True): 23 | super(FileBasedStorage, self).__init__() 24 | self.path = path 25 | self.multifile = multifile 26 | if not os.path.exists(path): 27 | if self.multifile: 28 | os.mkdir(path) 29 | else: 30 | self.dump_dict(path, {}) 31 | 32 | def dump_dict(self, filename, data): 33 | with open(filename, 'w', encoding='utf-8') as f: 34 | json.dump(data, f, indent=2, ensure_ascii=False) 35 | 36 | def load_dict(self, filename): 37 | if not os.path.exists(filename): 38 | return {} 39 | with open(filename, 'r', encoding='utf-8') as f: 40 | result = json.load(f) 41 | return result 42 | 43 | def get(self, key): 44 | if self.multifile: 45 | return self.load_dict(os.path.join(self.path, key)) 46 | else: 47 | return self.load_dict(self.path).get(key, {}) 48 | 49 | def set(self, key, value): 50 | if self.multifile: 51 | self.dump_dict(os.path.join(self.path, key), value) 52 | else: 53 | # todo: enable some concurrency guarantees 54 | data = self.load_dict(self.path) 55 | data[key] = value 56 | self.dump_dict(self.path, data) 57 | 58 | 59 | class MongoBasedStorage(BaseStorage): 60 | KEY_NAME = 'key' 61 | VALUE_NAME = 'value' 62 | 63 | def __init__(self, database=None, collection_name='sessions', collection=None): 64 | assert database is not None or collection is not None 65 | super(MongoBasedStorage, self).__init__() 66 | # we assume that the database has PyMongo interface 67 | if collection is None: 68 | self._collection = database.get_collection(collection_name) 69 | else: 70 | self._collection = collection 71 | database_utils.ensure_mongo_index(index_name=self.KEY_NAME, collection=self._collection) 72 | 73 | def get(self, key): 74 | result = self._collection.find_one({self.KEY_NAME: key}) 75 | if result is None: 76 | return {} 77 | return result.get(self.VALUE_NAME, {}) 78 | 79 | def set(self, key, value): 80 | value = fix_bson_keys(value) 81 | self._collection.update_one( 82 | {self.KEY_NAME: key}, 83 | {'$set': {self.VALUE_NAME: value}}, 84 | upsert=True 85 | ) 86 | 87 | 88 | class S3BasedStorage(BaseStorage): 89 | """ This wrapper is intended to work with a boto3 client - e.g. in Yandex.Cloud Object Storage. """ 90 | def __init__(self, s3_client, bucket_name, prefix=''): 91 | super(BaseStorage, self).__init__() 92 | self.s3_client = s3_client 93 | self.bucket_name = bucket_name 94 | self.prefix = prefix 95 | 96 | def modify_key(self, key): 97 | return self.prefix + key 98 | 99 | def get(self, key): 100 | try: 101 | result = self.s3_client.get_object(Bucket=self.bucket_name, Key=self.modify_key(key)) 102 | body = result['Body'] 103 | reader = codecs.getreader("utf-8") 104 | return json.load(reader(body)) 105 | except Exception as e: 106 | if hasattr(e, 'response') and e.response.get('Error', {}).get('Code') == 'NoSuchKey': 107 | return {} 108 | else: 109 | raise e 110 | 111 | def set(self, key, value): 112 | self.s3_client.put_object(Bucket=self.bucket_name, Key=self.modify_key(key), Body=json.dumps(value)) 113 | -------------------------------------------------------------------------------- /dialogic/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from . import testing_utils 2 | -------------------------------------------------------------------------------- /dialogic/testing/testing_utils.py: -------------------------------------------------------------------------------- 1 | from dialogic.dialog_manager import Context 2 | 3 | 4 | def make_context(text='', prev_response=None, new_session=False): 5 | if prev_response is not None: 6 | user_object = prev_response.updated_user_object 7 | else: 8 | user_object = {} 9 | if new_session: 10 | metadata = {'new_session': True} 11 | else: 12 | metadata = {} 13 | return Context(user_object=user_object, metadata=metadata, message_text=text) 14 | -------------------------------------------------------------------------------- /dialogic/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import collections, configuration, content_manager, serialization, database_utils, text 2 | -------------------------------------------------------------------------------- /dialogic/utils/collections.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | 4 | def make_unique(seq): 5 | """ Remove duplicates from a sequence (keep=first), without losing its ordering """ 6 | seen = set() 7 | # todo: make it work with dicts as well 8 | seen_add = seen.add 9 | return [x for x in seq if not (x in seen or seen_add(x))] 10 | 11 | 12 | def sample_at_most(seq, n=1): 13 | """ Sample min(n, len(seq)) unique elements from seq in random order """ 14 | seq = list(set(seq)) 15 | random.shuffle(seq) 16 | return seq[:n] 17 | -------------------------------------------------------------------------------- /dialogic/utils/configuration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import yaml 3 | 4 | from collections.abc import Iterable, Mapping 5 | 6 | 7 | def load_config(config, accept_lists=False, accept_dicts=True): 8 | if isinstance(config, str): 9 | with open(config, 'r', encoding='utf-8') as f: 10 | if config.endswith('.json'): 11 | return json.load(f) 12 | else: 13 | return yaml.safe_load(f) 14 | elif isinstance(config, Iterable) and accept_lists: 15 | return config 16 | elif isinstance(config, Mapping) and accept_dicts: 17 | return config 18 | else: 19 | text = 'Config should be a json/yaml filename' 20 | if accept_lists: 21 | text = text + ' or a list' 22 | if accept_dicts: 23 | text = text + ' or a dict' 24 | text = text + ', got "{}" instead.'.format(type(config)) 25 | raise ValueError(text) 26 | -------------------------------------------------------------------------------- /dialogic/utils/content_manager.py: -------------------------------------------------------------------------------- 1 | import attr 2 | import requests 3 | 4 | 5 | from typing import Dict, List, Optional 6 | 7 | 8 | @attr.s 9 | class Image: 10 | id: str = attr.ib() 11 | size: int = attr.ib() 12 | createdAt: str = attr.ib() 13 | origUrl: Optional[str] = attr.ib(default=None) 14 | 15 | 16 | class YandexImageAPI: 17 | """ 18 | This class is a wrapper around Yandex image storage API. 19 | Its official documentation is available online: 20 | https://yandex.ru/dev/dialogs/alice/doc/resource-upload-docpage/ 21 | """ 22 | def __init__(self, token, skill_id, default_image_id=None, upload_just_in_time=False): 23 | self.token = token 24 | self.skill_id = skill_id 25 | self.default_image_id = default_image_id 26 | self.upload_just_in_time = upload_just_in_time 27 | self.url2image: Dict[str, Image] = {} 28 | self.id2image: Dict[str, Image] = {} 29 | 30 | def update_images(self) -> None: 31 | """ Retrieve the list of images from the cloud storage and save it to the local index. """ 32 | for image in self.get_images_list(): 33 | if image.origUrl: 34 | self.url2image[image.origUrl] = image 35 | self.id2image[image.id] = image 36 | 37 | def add_image(self, url, timeout=5) -> Optional[Image]: 38 | """ Add image to the local index and Yandex storage by its url.""" 39 | if url in self.url2image: 40 | return self.url2image[url] 41 | result = self.upload_image(url, timeout=timeout) 42 | if result: 43 | self.url2image[url] = result 44 | self.id2image[result.id] = result 45 | return result 46 | 47 | def upload_image(self, url, timeout=5) -> Optional[Image]: 48 | """ 49 | Try to upload the image by url (without adding it to the local index) 50 | small images take 1.5-2 seconds to upload 51 | """ 52 | r = requests.post( 53 | url='https://dialogs.yandex.net/api/v1/skills/{}/images'.format(self.skill_id), 54 | headers={'Authorization': 'OAuth {}'.format(self.token)}, 55 | json={'url': url}, 56 | timeout=timeout, 57 | ) 58 | result = r.json().get('image') 59 | if result: 60 | return Image(**result) 61 | 62 | def get_images_list(self) -> List[Image]: 63 | """ Get all images in the Yandex storage. """ 64 | r = requests.get( 65 | url='https://dialogs.yandex.net/api/v1/skills/{}/images'.format(self.skill_id), 66 | headers={'Authorization': 'OAuth {}'.format(self.token)} 67 | ) 68 | results = r.json().get('images', []) 69 | return [Image(**item) for item in results] 70 | 71 | def get_image_id_by_url(self, url, try_upload=None, timeout=2, default=None) -> Optional[str]: 72 | """ 73 | Try to get image id from local storage or quickly upload it 74 | or return the default image. 75 | """ 76 | if url in self.url2image: 77 | return self.url2image[url].id 78 | if try_upload is None: 79 | try_upload = self.upload_just_in_time 80 | if try_upload: 81 | image = self.add_image(url, timeout=timeout) 82 | if image: 83 | return image.id 84 | if default: 85 | return default 86 | if self.default_image_id: 87 | return self.default_image_id 88 | 89 | def get_quota(self): 90 | """ Get existing an occupied amount of storage for images and sounds in bytes""" 91 | r = requests.get( 92 | url='https://dialogs.yandex.net/api/v1/status', 93 | headers={'Authorization': 'OAuth {}'.format(self.token)} 94 | ) 95 | return r.json() 96 | 97 | def delete_image(self, image_id): 98 | """ Delete image from storage by its id and delete it from local index """ 99 | r = requests.delete( 100 | url='https://dialogs.yandex.net/api/v1/skills/{}/images/{}'.format(self.skill_id, image_id), 101 | headers={'Authorization': 'OAuth {}'.format(self.token)} 102 | ) 103 | if r.ok: 104 | if image_id in self.id2image: 105 | image = self.id2image[image_id] 106 | del self.id2image[image_id] 107 | if image.origUrl in self.url2image and self.url2image[image.origUrl].id == image_id: 108 | del self.url2image[image.origUrl] 109 | return r.json() 110 | -------------------------------------------------------------------------------- /dialogic/utils/database_utils.py: -------------------------------------------------------------------------------- 1 | 2 | def ensure_mongo_index(index_name, collection, unique=False, index_type='hashed'): 3 | if index_name not in collection.index_information(): 4 | collection.create_index([(index_name, index_type)], unique=unique) 5 | -------------------------------------------------------------------------------- /dialogic/utils/serialization.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Mapping 3 | 4 | 5 | def list_converter(cls): 6 | def converter(item): 7 | return [cls.from_dict(x) for x in (item or [])] 8 | return converter 9 | 10 | 11 | def dict_converter(cls): 12 | def converter(item): 13 | return {k: cls.from_dict(v) for k, v in (item or {}).items()} 14 | return converter 15 | 16 | 17 | def try_serialize(item): 18 | if hasattr(item, 'to_dict'): 19 | return item.to_dict() 20 | return item 21 | 22 | 23 | class Serializeable: 24 | """ This mixin is used for easy conversion of structured objects from and to json """ 25 | 26 | @classmethod 27 | def from_dict(cls, data, from_none=False): 28 | if data is None: 29 | if from_none: 30 | data = {} 31 | else: 32 | return None 33 | if isinstance(data, cls): 34 | return data 35 | # assume all the heavy lifting is done by converters in the cls.__init__ 36 | return cls(**data) 37 | 38 | def to_dict(self): 39 | result = {} 40 | for field_name, field in self.__dict__.items(): 41 | if isinstance(field, list): 42 | field = [try_serialize(item) for item in field] 43 | elif isinstance(field, dict): 44 | field = {k: try_serialize(v) for k, v in field.items()} 45 | else: 46 | field = try_serialize(field) 47 | result[field_name] = field 48 | return result 49 | 50 | 51 | class FreeSerializeable(Serializeable): 52 | """ A serializeable object that can accept and preserve arbitrary extra keys. """ 53 | @classmethod 54 | def from_dict(cls, data, from_none=False): 55 | if isinstance(data, Mapping) and not isinstance(data, cls): 56 | arg_names = set(inspect.signature(cls.__init__).parameters) 57 | args = {k: v for k, v in data.items() if k in arg_names} 58 | other = {k: v for k, v in data.items() if k not in arg_names} 59 | else: 60 | args = data 61 | other = None 62 | result = super(FreeSerializeable, cls).from_dict(args, from_none=from_none) 63 | if result is not None: 64 | result._other = other 65 | return result 66 | 67 | def to_dict(self): 68 | result = super(FreeSerializeable, self).to_dict() 69 | if hasattr(self, '_other') and self._other: 70 | result.update(self._other) 71 | if '_other' in result: 72 | del result['_other'] 73 | return result 74 | -------------------------------------------------------------------------------- /dialogic/utils/text.py: -------------------------------------------------------------------------------- 1 | import urllib.request 2 | 3 | 4 | def encode_uri(url): 5 | """ Produce safe ASCII urls, like in the Russian Wikipedia""" 6 | return urllib.request.quote(url, safe='~@#$&()*!+=:;,.?/\'') 7 | -------------------------------------------------------------------------------- /examples/automaton/menu.py: -------------------------------------------------------------------------------- 1 | import dialogic 2 | 3 | 4 | if __name__ == '__main__': 5 | manager = dialogic.dialog_manager.CascadeDialogManager( 6 | dialogic.dialog_manager.AutomatonDialogManager('menu.yaml', matcher='cosine'), 7 | dialogic.dialog_manager.GreetAndHelpDialogManager( 8 | greeting_message="Дефолтное приветственное сообщение", 9 | help_message="Дефолтный вызов помощи", 10 | default_message='Я вас не понимаю.', 11 | exit_message='Всего доброго! Было приятно с вами пообщаться!' 12 | ) 13 | ) 14 | connector = dialogic.dialog_connector.DialogConnector( 15 | dialog_manager=manager, 16 | storage=dialogic.storage.session_storage.BaseStorage() 17 | ) 18 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 19 | server.parse_args_and_run() 20 | -------------------------------------------------------------------------------- /examples/automaton/menu.yaml: -------------------------------------------------------------------------------- 1 | options: 2 | initial_state: MAIN 3 | 4 | intents: 5 | CONFIRM_YES: 6 | examples: 7 | - да 8 | - ага 9 | - конечно 10 | - давай 11 | CONFIRM_NO: 12 | examples: 13 | - нет 14 | - не надо 15 | 16 | states: # aka nodes 17 | MAIN: 18 | q: 19 | - Меню 20 | - Главное меню 21 | a: 'Приветствую! Выберите опцию: чай, кофе, или потанцуем.' 22 | next: 23 | - suggest: Чай 24 | label: TEA 25 | - suggest: Кофе 26 | label: COFFEE 27 | - suggest: Танцевать 28 | label: DANCE 29 | TEA: 30 | a: Какого чаю хотите? Чёрного, зелёного, или красного? 31 | next: 32 | - suggest: Чёрного 33 | regexp: черн.* 34 | label: BLACK_TEA 35 | - suggest: Зелёного 36 | regexp: зелен.* 37 | label: GREEN_TEA 38 | - suggest: Красного 39 | regexp: красн.* 40 | label: RED_TEA 41 | COFFEE: 42 | a: Какого кофе хотите? Эспрессо, американо, капучино, латте? 43 | default_next: NO_COFFEE 44 | DANCE: 45 | q: 46 | - танцевать 47 | - потанцуем 48 | - плясать 49 | - танец 50 | a: Круто, давайте плясать! 51 | RED_TEA: 52 | a: Вот вам лучший каркаде Египта, угощайтесь! 53 | GREEN_TEA: 54 | q: 55 | - зеленый чай 56 | - улун 57 | a: Завариваю для вас редкий тибетский улун. 58 | BLACK_TEA: 59 | a: Сейчас сделаю вам чёрного чайку покрепче 60 | RANDOM: 61 | q: 62 | - посоветуй 63 | - дай совет 64 | - порекомендуй что-нибудь 65 | - выбери за меня 66 | a: 67 | - Хотите чаю? 68 | next: 69 | - suggest: Да 70 | intent: CONFIRM_YES 71 | label: TEA 72 | - suggest: Нет 73 | intent: CONFIRM_NO 74 | label: MAIN 75 | NO_COFFEE: 76 | a: Простите, кофе закончился. Хотите чаю? 77 | next: 78 | - suggest: Да 79 | intent: CONFIRM_YES 80 | label: TEA 81 | - suggest: Нет 82 | intent: CONFIRM_NO 83 | label: MAIN 84 | -------------------------------------------------------------------------------- /examples/cascade/main.py: -------------------------------------------------------------------------------- 1 | from dialogic.dialog_connector import DialogConnector 2 | from dialogic.dialog_manager import TurnDialogManager 3 | from dialogic.server.flask_server import FlaskServer 4 | from dialogic.cascade import DialogTurn, Cascade 5 | 6 | csc = Cascade() 7 | 8 | 9 | @csc.add_handler(priority=10, regexp='(hello|hi|привет|здравствуй)') 10 | def hello(turn: DialogTurn): 11 | turn.response_text = 'Hello! This is the only conditional phrase I have.' 12 | 13 | 14 | @csc.add_handler(priority=1) 15 | def fallback(turn: DialogTurn): 16 | turn.response_text = 'Hi! Sorry, I do not understand you.' 17 | turn.suggests.append('hello') 18 | 19 | 20 | dm = TurnDialogManager(cascade=csc) 21 | connector = DialogConnector(dialog_manager=dm) 22 | server = FlaskServer(connector=connector) 23 | 24 | if __name__ == '__main__': 25 | server.parse_args_and_run() 26 | -------------------------------------------------------------------------------- /examples/controls.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import logging 3 | import dialogic 4 | 5 | 6 | logging.basicConfig(level=logging.DEBUG) 7 | 8 | 9 | SHOW_OBJECT_GALLERY = 'Объектная галерея' 10 | SHOW_JSON_GALLERY = 'Галерея на json' 11 | SHOW_JSON_TEXT = 'Текст на json' 12 | SHOW_LOAD = 'Загрузка элементов' 13 | 14 | SUGGESTS = [SHOW_JSON_GALLERY, SHOW_OBJECT_GALLERY, SHOW_JSON_TEXT, SHOW_LOAD] 15 | 16 | 17 | class ControlsDialogManager(dialogic.dialog_manager.BaseDialogManager): 18 | def respond(self, ctx): 19 | response = dialogic.dialog_manager.Response('это текст по умолчанию') 20 | if ctx.message_text.lower() == SHOW_OBJECT_GALLERY.lower(): 21 | response.gallery = dialogic.nlg.controls.Gallery( 22 | title='Большая галерея', 23 | items=[ 24 | dialogic.nlg.controls.GalleryItem(title='Первый элемент'), 25 | dialogic.nlg.controls.GalleryItem(title='Второй элемент'), 26 | ], 27 | footer=dialogic.nlg.controls.GalleryFooter(text='Низ', button_payload={'нажал': 'подвал'}) 28 | ) 29 | elif ctx.message_text.lower() == SHOW_JSON_GALLERY.lower(): 30 | response.raw_response = RAW_GALLERY 31 | elif ctx.message_text.lower() == SHOW_JSON_TEXT.lower(): 32 | response.raw_response = RAW_TEXT 33 | elif ctx.message_text.lower() == SHOW_LOAD.lower(): 34 | response.set_text('Вот тут ссыль ссыль') 35 | response.suggests.extend(SUGGESTS) 36 | return response 37 | 38 | 39 | RAW_TEXT = { 40 | "text": "Здравствуйте! Это мы, хороводоведы.", 41 | "tts": "Здравствуйте! Это мы, хоров+одо в+еды.", 42 | "buttons": [ 43 | { 44 | "title": "Скрываемая ссылка", 45 | "payload": {}, 46 | "url": "https://example.com/", 47 | "hide": True 48 | }, 49 | { 50 | "title": "Нескрываемая ссылка", 51 | "payload": {}, 52 | "url": "https://example.com/", 53 | "hide": False 54 | }, 55 | { 56 | "title": "Скрываемая нессылка", 57 | "payload": {}, 58 | "hide": True 59 | }, 60 | { 61 | "title": "Нескрываемая нессылка", 62 | "payload": {}, 63 | "hide": False 64 | }, 65 | {"title": SHOW_JSON_GALLERY}, 66 | {"title": SHOW_OBJECT_GALLERY}, 67 | ], 68 | "end_session": False 69 | } 70 | 71 | 72 | RAW_GALLERY = copy.deepcopy(RAW_TEXT) 73 | RAW_GALLERY['card'] = { 74 | "type": "ItemsList", 75 | "header": { 76 | "text": "Заголовок галереи изображений", 77 | }, 78 | "items": [ 79 | { 80 | "image_id": "1030494/9409bc880f9d7a5d571b", 81 | "title": "Заголовок для изображения.", 82 | "description": "Описание изображения.", 83 | "button": { 84 | "text": "Надпись на кнопке", 85 | "url": "http://example.com/", 86 | "payload": {} 87 | } 88 | }, 89 | { 90 | "image_id": "1030494/9409bc880f9d7a5d571b", 91 | "title": "Вторая картинка", 92 | "description": "Её описание.", 93 | "button": { 94 | "text": "Кнопка со ссылкой и лоадом", 95 | "url": "http://example.com/", 96 | "payload": {"key": "value"} 97 | } 98 | }, 99 | { 100 | "title": "Текст без картинки и ссылки", 101 | "description": "Тут пэйлоад и текст кнопки", 102 | "button": { 103 | "text": "Кнопка со ссылкой и лоадом", 104 | "payload": {"key": "value"} 105 | } 106 | }, 107 | { 108 | "title": "Текст без картинки и ссылки", 109 | "description": "Тут пэйлоад и кнопка без текста", 110 | "button": { 111 | "payload": {"key": "value"} 112 | } 113 | } 114 | ], 115 | "footer": { 116 | "text": "Текст блока под изображением.", 117 | "button": { 118 | "text": "Надпись на кнопке", 119 | "url": "https://example.com/", 120 | "payload": {} 121 | } 122 | } 123 | } 124 | 125 | if __name__ == '__main__': 126 | connector = dialogic.dialog_connector.DialogConnector( 127 | dialog_manager=ControlsDialogManager(), 128 | storage=dialogic.storage.session_storage.BaseStorage(), 129 | tg_suggests_cols=2, 130 | ) 131 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 132 | server.parse_args_and_run() 133 | -------------------------------------------------------------------------------- /examples/faq/faq.py: -------------------------------------------------------------------------------- 1 | import dialogic 2 | import logging 3 | 4 | logging.basicConfig(level=logging.DEBUG) 5 | 6 | 7 | TEXT_HELP = ( 8 | 'Привет! Я бот, который умеет работать и в Телеграме и в Алисе.' 9 | '\nЯ не умею делать примерно ничего, но могу с вами поздороваться.' 10 | '\nКогда вам надоест со мной говорить, скажите "выход".' 11 | ) 12 | TEXT_FAREWELL = 'Всего доброго! Если захотите повторить, скажите "Алиса, включи навык тест dialogic".' 13 | 14 | 15 | if __name__ == '__main__': 16 | manager = dialogic.dialog_manager.CascadeDialogManager( 17 | dialogic.dialog_manager.FAQDialogManager('faq.yaml', matcher='cosine'), 18 | dialogic.dialog_manager.GreetAndHelpDialogManager( 19 | greeting_message=TEXT_HELP, 20 | help_message=TEXT_HELP, 21 | default_message='Я вас не понимаю.', 22 | exit_message='Всего доброго! Было приятно с вами пообщаться!' 23 | ) 24 | ) 25 | connector = dialogic.dialog_connector.DialogConnector( 26 | dialog_manager=manager, 27 | storage=dialogic.storage.session_storage.BaseStorage() 28 | ) 29 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 30 | server.parse_args_and_run() 31 | -------------------------------------------------------------------------------- /examples/faq/faq.yaml: -------------------------------------------------------------------------------- 1 | # FAQ config is a list of Q&A pairs 2 | - q: # a question may contain a list of possible patterns 3 | - привет 4 | - здравствуй 5 | a: # an answer may contain a list of alternatives (to choose at random) 6 | - Привет-привет! 7 | - Дратути! 8 | - И вам доброго времени суток, человек. 9 | s: # there may be one or more suggest buttons 10 | - Как дела? 11 | - Что ты умеешь? 12 | 13 | - q: как дела # alternatively, the question and/or the answer may consist of a single text 14 | a: Нормально 15 | -------------------------------------------------------------------------------- /examples/faq_neural/README.md: -------------------------------------------------------------------------------- 1 | This example illustrates how a `dialogic` bot could work in 2 | two modes: FAQ (default) and chitchat (fallback mode). 3 | Both modes are implemented based on neural networks: 4 | a BERT-based encoder for FAQ matching, 5 | and a T5-based model for chitchat response generation. 6 | 7 | The interface is usual: to play with a commandline demo, run 8 | ```commandline 9 | pip install -r requrements.txt 10 | python main.py --cli 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/faq_neural/faq.yaml: -------------------------------------------------------------------------------- 1 | # FAQ config is a list of Q&A pairs 2 | - q: # a question may contain a list of possible patterns 3 | - привет 4 | - здравствуй 5 | a: # an answer may contain a list of alternatives (to choose at random) 6 | - Привет-привет! 7 | - Дратути! 8 | - И вам доброго времени суток, человек. 9 | s: # there may be one or more suggest buttons 10 | - Как дела? 11 | - Что ты умеешь? 12 | 13 | - q: как дела # alternatively, the question and/or the answer may consist of a single text 14 | a: Нормально 15 | 16 | - q: # initial message 17 | - '' 18 | - '/start' 19 | a: 20 | - Привет! Это пример чатбота на платформе dialogic, в котором используется FAQ и болталка. О чём поболтаем? 21 | -------------------------------------------------------------------------------- /examples/faq_neural/main.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import dialogic 3 | import logging 4 | 5 | from dialogic.dialog import Context, Response 6 | from models import VectorMatcher, respond_with_gpt 7 | 8 | logging.basicConfig(level=logging.DEBUG) 9 | 10 | 11 | class ChitChatDialogManager(dialogic.dialog_manager.CascadableDialogManager): 12 | def try_to_respond(self, ctx: Context) -> typing.Union[Response, None]: 13 | return Response(respond_with_gpt(ctx.message_text)) 14 | 15 | 16 | if __name__ == '__main__': 17 | manager = dialogic.dialog_manager.CascadeDialogManager( 18 | dialogic.dialog_manager.FAQDialogManager('faq.yaml', matcher=VectorMatcher()), 19 | ChitChatDialogManager() 20 | ) 21 | connector = dialogic.dialog_connector.DialogConnector( 22 | dialog_manager=manager, 23 | storage=dialogic.storage.session_storage.BaseStorage() 24 | ) 25 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 26 | server.parse_args_and_run() 27 | -------------------------------------------------------------------------------- /examples/faq_neural/models.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from transformers import AutoModelForSeq2SeqLM, AutoTokenizer, AutoModel 3 | 4 | from dialogic.nlu.matchers import PairwiseMatcher 5 | 6 | CHITCHAT_MODEL_NAME = 'cointegrated/rut5-small-chitchat' 7 | EMBEDDER_MODEL_NAME = 'cointegrated/rubert-tiny2' 8 | 9 | chichat_model = AutoModelForSeq2SeqLM.from_pretrained(CHITCHAT_MODEL_NAME) 10 | chichat_tokenizer = AutoTokenizer.from_pretrained(CHITCHAT_MODEL_NAME) 11 | 12 | embedder_model = AutoModel.from_pretrained(EMBEDDER_MODEL_NAME) 13 | embedder_tokenizer = AutoTokenizer.from_pretrained(EMBEDDER_MODEL_NAME) 14 | 15 | 16 | def respond_with_gpt(text: str): 17 | inputs = chichat_tokenizer(text, return_tensors='pt').to(chichat_model.device) 18 | hypotheses = chichat_model.generate( 19 | **inputs, 20 | do_sample=True, 21 | top_p=0.5, 22 | num_return_sequences=1, 23 | repetition_penalty=2.5, 24 | max_length=32, 25 | ) 26 | return chichat_tokenizer.decode(hypotheses[0], skip_special_tokens=True) 27 | 28 | 29 | def encode_with_bert(text: str): 30 | t = embedder_tokenizer(text, padding=True, truncation=True, return_tensors='pt') 31 | with torch.inference_mode(): 32 | model_output = embedder_model(**{k: v.to(embedder_model.device) for k, v in t.items()}) 33 | embeddings = model_output.last_hidden_state[:, 0, :] 34 | embeddings = torch.nn.functional.normalize(embeddings) 35 | return embeddings[0].cpu().numpy() 36 | 37 | 38 | class VectorMatcher(PairwiseMatcher): 39 | def __init__(self, text_normalization=None, threshold=0.9, **kwargs): 40 | super().__init__(text_normalization=text_normalization, threshold=threshold, **kwargs) 41 | 42 | def preprocess(self, text): 43 | return encode_with_bert(text) 44 | 45 | def compare(self, one, another): 46 | # dot product of normalized vectors is cosine distance 47 | return sum(one * another) 48 | -------------------------------------------------------------------------------- /examples/faq_neural/requirements.txt: -------------------------------------------------------------------------------- 1 | torch 2 | transformers 3 | dialogic 4 | -------------------------------------------------------------------------------- /examples/form_filling/form.py: -------------------------------------------------------------------------------- 1 | import json 2 | import dialogic 3 | 4 | HELP_MESSAGE = 'Hi! This is a math test chat. Say "Start test" to start the test.' 5 | 6 | 7 | def handle_full_form(form, user_object, ctx): 8 | return dialogic.dialog_manager.Response( 9 | "That's all. Thank you!\nYour form is \n{}\n and it will be graded soon".format( 10 | json.dumps(form['fields'], indent=2) 11 | ), 12 | user_object=user_object 13 | ) 14 | 15 | 16 | if __name__ == '__main__': 17 | connector = dialogic.dialog_connector.DialogConnector( 18 | dialog_manager=dialogic.dialog_manager.FormFillingDialogManager('form.yaml', default_message=HELP_MESSAGE), 19 | storage=dialogic.storage.session_storage.BaseStorage() 20 | ) 21 | connector.dialog_manager.handle_completed_form = handle_full_form 22 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 23 | server.parse_args_and_run() 24 | -------------------------------------------------------------------------------- /examples/form_filling/form.yaml: -------------------------------------------------------------------------------- 1 | form_name: 'math_test_form' 2 | 3 | start: 4 | regexp: 'start (the )?test' 5 | # message: Okay, let's start the test! I will ask 3 difficult math questions. Say anything to start. 6 | suggests: 7 | - Start! 8 | - Exit the test 9 | 10 | fields: 11 | - name: 'name' 12 | question: Please tell me your name 13 | - name: 'q1' 14 | question: Calculate 2 + 2 * 2 15 | validate_regexp: '\-?\d+$' 16 | validate_message: Please try again. Your answer should be a whole number. 17 | - name: 'q2' 18 | question: What is larger, log(1 + 2 + 3) or log(1) + log(2) + log(3) ? 19 | options: 20 | - log(1 + 2 + 3) 21 | - log(1) + log(2) + log(3) 22 | - they are equal 23 | - it depends on the base of the logarithm 24 | - some of these expressions are invalid 25 | validate_message: The answer should be one of the suggested options. 26 | - name: 'q3' 27 | question: Calculate (sin(18°) + sin(72°))² - sin(18°)sin(72°) - cos(18°)cos(72°) 28 | validate_regexp: '\-?\d+(.\d+)?$' 29 | validate_message: Please try again. Your answer should be a whole or a decimal number. 30 | 31 | finish: 32 | message: Thank you for taking the test! Your results will be graded soon. 33 | 34 | exit: 35 | suggest: 'Exit the test' 36 | regexp: '(exit|quit)( the)? test' 37 | message: You have chosen to quit the test. If you want to take it again, say "start the test" 38 | -------------------------------------------------------------------------------- /examples/heroku/Procfile: -------------------------------------------------------------------------------- 1 | web: python multimedia.py 2 | -------------------------------------------------------------------------------- /examples/heroku/requirements.txt: -------------------------------------------------------------------------------- 1 | dialogic 2 | -------------------------------------------------------------------------------- /examples/multimedia.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import dialogic 4 | 5 | logging.basicConfig(level=logging.DEBUG) 6 | 7 | MARINA_SONG_IN_ALICE = '' # noqa 8 | MARINA_SONG_IN_WEB = 'https://filebin.net/s0tbsdw97u6jz1u0/marina.mp3?t=ka8zlp2p' 9 | IMAGE_ID_IN_ALICE = '213044/0c6463a2a6eb7f935034' 10 | IMAGE_IN_WEB = 'https://i.pinimg.com/originals/43/94/ab/4394abfe9d1a8feeeedfccc41c0e9df2.gif' 11 | 12 | 13 | class ExampleMediaDialogManager(dialogic.dialog_manager.BaseDialogManager): 14 | def respond(self, ctx): 15 | response = dialogic.dialog_manager.Response(text='please take it', user_object=ctx.user_object) 16 | has_context = False 17 | text = ctx.message_text.lower() 18 | if re.match('.*(image|picture).*', text): 19 | response.image_id = IMAGE_ID_IN_ALICE 20 | response.image_url = IMAGE_IN_WEB 21 | has_context = True 22 | if re.match('.*sound.*', text): 23 | has_context = True 24 | response.set_text(response.voice + '(only in alice) ') 25 | if re.match('.*(music|sing|song).*', text): 26 | has_context = True 27 | response.set_text(response.voice + ' ' + MARINA_SONG_IN_ALICE) 28 | response.sound_url = MARINA_SONG_IN_WEB 29 | if not has_context: 30 | response.set_text('I can send a picture or make a sound.') 31 | response.suggests = ['send a picture', 'make a sound', 'play music'] 32 | return response 33 | 34 | 35 | if __name__ == '__main__': 36 | connector = dialogic.dialog_connector.DialogConnector( 37 | dialog_manager=ExampleMediaDialogManager(), 38 | storage=dialogic.storage.session_storage.BaseStorage() 39 | ) 40 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 41 | server.parse_args_and_run() 42 | -------------------------------------------------------------------------------- /examples/readme.md: -------------------------------------------------------------------------------- 1 | This example shows how to actually build a bot that can be deployed e.g. on Heroku. 2 | 3 | #### About the contents 4 | 5 | - `faq.py` shows how to create a simple Q&A bot with a text-based config `faq.yaml`. 6 | - `state.py` shows how you can create a custom dialog manager and track dialogue state (number of messages). 7 | - `form.py` shows how to configure a sequence of questions. 8 | - `controls.py` and `multimedia.py` show how to work with inputs and outputs other than pure text. 9 | 10 | The file `requirements.txt` describes the packages required to run the bot 11 | (only `dialogic` and `flask` in this example). 12 | 13 | The file `Procfile` is needed only for Heroku: it shows what to run when your bot is deployed. 14 | 15 | #### Deploy in command line mode 16 | To run the bot locally (in the command line mode, without Internet connection) you need no specific setup, 17 | except installing the requirements (`pip install -r requirements.txt`, if you don't have them yet). 18 | The argument `--cli` enables command line mode. 19 | 20 | For example, to run the example `faq.py` in the command line mode, you need to type in the command line (Windows) 21 | ``` 22 | cd 23 | python faq.py --cli 24 | ``` 25 | 26 | #### Local deploy for Telegram 27 | To run the bot in the polling mode (for Telegram only), you need to set an environment variable `TOKEN` 28 | to the token of your Telegram bot (given to you by t.me/botfather when you create a bot). 29 | 30 | For example, to run the example `faq.py` locally for Telegram, you need to type in the command line (Windows) 31 | ``` 32 | cd 33 | set TOKEN= 34 | python faq.py --poll 35 | ``` 36 | (if you are not on Windows, you probably already know what to do). 37 | 38 | #### Web deploy 39 | To run the bot on the server (for both Alice and Telegram), you need to set the token 40 | and the environment variable `BASE_URL` 41 | to the address of your application (such as `https://my-cool-app.herokuapp.com/`). 42 | After you deploy it, you can use the url `/alice/` as a webhook for an 43 | [Alice skill](https://tech.yandex.ru/dialogs/alice/). 44 | -------------------------------------------------------------------------------- /examples/state.py: -------------------------------------------------------------------------------- 1 | import dialogic 2 | 3 | 4 | TEXT_HELP = ( 5 | 'Привет! Я бот, который умеет работать и в Телеграме и в Алисе.' 6 | '\nПоскольку это пример, я просто повторяю ваши слова и считаю ваши сообщения.' 7 | '\nКогда вам надоест, скажите "довольно" или "Алиса, хватит".' 8 | ) 9 | TEXT_FAREWELL = 'Всего доброго! Если захотите повторить, скажите "Алиса, включи навык тест dialogic".' 10 | 11 | 12 | class ExampleDialogManager(dialogic.dialog_manager.BaseDialogManager): 13 | def respond(self, ctx): 14 | suggests = ['довольно'] 15 | user_object = ctx.user_object 16 | count = user_object.get('count', -1) + 1 17 | commands = [] 18 | text = dialogic.nlu.basic_nlu.fast_normalize(ctx.message_text) 19 | 20 | if not text or dialogic.nlu.basic_nlu.like_help(text) or not ctx.user_object or text == '/start': 21 | response = TEXT_HELP 22 | elif text == 'довольно' or dialogic.nlu.basic_nlu.like_exit(text): 23 | response = TEXT_FAREWELL 24 | commands.append(dialogic.dialog_manager.COMMANDS.EXIT) 25 | else: 26 | response = 'Вы только что сказали "{}". Всего вы сказали {}.'.format(ctx.message_text, self._count(count)) 27 | 28 | user_object['count'] = count 29 | return dialogic.dialog_manager.Response( 30 | user_object=user_object, text=response, suggests=suggests, commands=commands 31 | ) 32 | 33 | @staticmethod 34 | def _count(count): 35 | ones = count % 10 36 | tens = (count // 10) % 10 37 | if ones == 1 and tens != 1: 38 | return '{} команду'.format(count) 39 | elif ones in {2, 3, 4} and tens != 1: 40 | return '{} команды'.format(count) 41 | else: 42 | return '{} команд'.format(count) 43 | 44 | 45 | if __name__ == '__main__': 46 | connector = dialogic.dialog_connector.DialogConnector( 47 | dialog_manager=ExampleDialogManager(), 48 | storage=dialogic.storage.session_storage.BaseStorage(), 49 | log_storage=dialogic.storage.message_logging.MongoMessageLogger() 50 | ) 51 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 52 | server.parse_args_and_run() 53 | -------------------------------------------------------------------------------- /examples/vk_api/readme.md: -------------------------------------------------------------------------------- 1 | These examples show how to use VK API without the rest of `dialogic`. 2 | 3 | When creating this interface, I intended it to be similar to 4 | https://github.com/eternnoir/pyTelegramBotAPI. 5 | 6 | To attach a bot to your own VK group, you should register it. 7 | The instruction is given by 8 | https://vk.com/dev/bots_docs. 9 | 10 | Before running the scripts, you should set two environment variables, 11 | `VK_TOKEN` and `VK_GROUP_ID`. 12 | -------------------------------------------------------------------------------- /examples/vk_api/vkbot_callback.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from flask import Flask, request 5 | 6 | from dialogic.interfaces.vk import VKBot, VKMessage 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logging.getLogger('dialogic.interfaces.vk').setLevel(logging.DEBUG) 10 | 11 | 12 | app = Flask(__name__) 13 | 14 | 15 | bot = VKBot( 16 | token=os.environ['VK_TOKEN'], 17 | group_id=os.environ['VK_GROUP_ID'], 18 | ) 19 | 20 | 21 | @bot.message_handler() 22 | def respond(message: VKMessage): 23 | bot.send_message( 24 | peer_id=message.user_id, 25 | text='Вы написали {}'.format(message.text), 26 | keyboard={'buttons': [[{'action': {'type': 'text', 'label': 'ок'}}]]}, 27 | ) 28 | 29 | 30 | @app.route('/vk-callback', methods=['POST']) 31 | def respond_to_callback(): 32 | return bot.process_webhook_data(request.json) 33 | 34 | 35 | if __name__ == '__main__': 36 | # change this webhook address to the address of your sever 37 | # if you are running this script locally, you may want to use ngrok to create a tunnel to your local machine 38 | bot.set_postponed_webhook('https://761c8f3c.eu.ngrok.io/vk-callback', remove_old=True) 39 | # if debug=True, this code is run twice, which may result in setting two webhooks 40 | app.run(host='0.0.0.0', port=5000, debug=False) 41 | -------------------------------------------------------------------------------- /examples/vk_api/vkbot_polling.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from dialogic.interfaces.vk import VKBot, VKMessage 5 | 6 | logging.basicConfig(level=logging.DEBUG) 7 | 8 | bot = VKBot( 9 | token=os.environ['VK_TOKEN'], 10 | group_id=os.environ['VK_GROUP_ID'], 11 | polling_wait=3, # normally, timeout is about 20 seconds, but we make it shorter for quicker feedback 12 | ) 13 | 14 | 15 | @bot.message_handler() 16 | def respond(message: VKMessage): 17 | bot.send_message( 18 | peer_id=message.user_id, 19 | text='Вы написали {}'.format(message.text), 20 | keyboard={ 21 | 'one_time': True, 22 | 'buttons': [[{ 23 | 'action': {'type': 'text', 'label': 'окей'}, 24 | 'color': 'secondary', 25 | }]] 26 | }, 27 | ) 28 | 29 | 30 | if __name__ == '__main__': 31 | bot.polling() 32 | -------------------------------------------------------------------------------- /examples/yandex_state.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file illustrates several techniques: 3 | - how to store user object (session + user) in Alice 4 | - how to extract intents from Yandex native NLU 5 | """ 6 | 7 | import dialogic 8 | 9 | 10 | class ExampleDialogManager(dialogic.dialog_manager.BaseDialogManager): 11 | def respond(self, ctx): 12 | if ctx.source != dialogic.SOURCES.ALICE: 13 | return dialogic.dialog.Response('Простите, но я работаю только в Алисе.') 14 | suggests = ['меня зовут иван', 'как меня зовут', 'сколько было сессий', 'повтори'] 15 | uo = ctx.user_object 16 | if 'user' not in uo: 17 | uo['user'] = {} 18 | if 'session' not in uo: 19 | uo['session'] = {} 20 | 21 | intents = ctx.yandex.request.nlu.intents 22 | if ctx.session_is_new(): 23 | uo['user']['sessions'] = uo['user'].get('sessions', 0) + 1 24 | text = 'Привет! Вы находитесь в тестовом навыке. Чтобы выйти, скажите "Алиса, хватит".' 25 | elif 'set_name' in intents: 26 | name = intents['set_name'].slots['name'].value 27 | text = 'Запомнила, вас зовут {}'.format(name) 28 | uo['user']['name'] = name 29 | elif 'get_name' in intents: 30 | if uo['user'].get('name'): 31 | text = 'Кажется, ваше имя {}'.format(uo['user']['name']) 32 | else: 33 | text = 'Я не помню, как вас зовут. Пожалуйста, представьтесь.' 34 | elif 'YANDEX.REPEAT' in intents: 35 | if 'last_phrase' in uo['session']: 36 | text = uo['session']['last_phrase'] 37 | else: 38 | text = 'Не помню, о чем мы говорили' 39 | else: 40 | text = 'У нас с вами было уже {} разговоров!'.format(uo['user'].get('sessions', 0)) 41 | 42 | uo['session']['last_phrase'] = text 43 | return dialogic.dialog_manager.Response(user_object=uo, text=text, suggests=suggests) 44 | 45 | 46 | if __name__ == '__main__': 47 | connector = dialogic.dialog_connector.DialogConnector( 48 | dialog_manager=ExampleDialogManager(), 49 | alice_native_state=True, 50 | ) 51 | server = dialogic.server.flask_server.FlaskServer(connector=connector) 52 | server.parse_args_and_run() 53 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README_en.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import setuptools 3 | 4 | description = "Yet another common wrapper for Alice/Salut skills and Facebook/Telegram/VK bots" 5 | long_description = description 6 | if os.path.exists("README_en.md"): 7 | with open("README_en.md", "r", encoding="utf-8") as fh: 8 | long_description = fh.read() 9 | 10 | 11 | setuptools.setup( 12 | name="dialogic", 13 | version="0.3.20", 14 | author="David Dale", 15 | author_email="dale.david@mail.ru", 16 | description=description, 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | url="https://github.com/avidale/dialogic", 20 | packages=setuptools.find_packages(), 21 | license="MIT", 22 | classifiers=[ 23 | "Development Status :: 3 - Alpha", 24 | "Programming Language :: Python :: 3", 25 | "License :: OSI Approved :: MIT License", 26 | "Operating System :: OS Independent", 27 | ], 28 | install_requires=[ 29 | 'attrs', 30 | 'flask', 31 | 'pymessenger', 32 | 'pymorphy2', 33 | 'pyTelegramBotAPI', 34 | 'pyyaml', 35 | 'requests', 36 | 'textdistance', 37 | 'colorama', 38 | ], 39 | extras_require={ 40 | 'rumorph': ['pymorphy2[fast]', 'pymorphy2-dicts-ru'], # todo: move them out of main requirements 41 | 'server': ['flask', 'pymessenger', 'pyTelegramBotAPI'], # todo: move them out of main requirements 42 | 'w2v': ['numpy', 'pyemd'], 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avidale/dialogic/c5b0da161da5138ce0bea157a98421b148406970/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avidale/dialogic/c5b0da161da5138ce0bea157a98421b148406970/tests/test_adapters/__init__.py -------------------------------------------------------------------------------- /tests/test_adapters/test_vk_adapter.py: -------------------------------------------------------------------------------- 1 | from dialogic.adapters import VkAdapter 2 | from dialogic.dialog import Response 3 | 4 | 5 | def test_keyboard_squeeze(): 6 | resp = Response(text='не важно', suggests=[ 7 | 'удалить направление', 'Агропромышленный комплекс', 'Вооружение и военная техника', 8 | 'Естественные науки', 'Инженерные науки и технологии', 'Искусство и гуманитарные науки', 9 | 'Компьютерные науки', 'Медицина и здравоохранение', 'Педагогические науки', 10 | 'Социально-экономические науки', 'вакансии', 'мой регион', 'мои направления', 'главное меню' 11 | ]) 12 | adapter = VkAdapter(suggest_cols='auto') 13 | result = adapter.make_response(resp) 14 | keyboard = result['keyboard']['buttons'] 15 | assert len(keyboard) == 10 16 | assert sum(len(row) for row in keyboard) == len(resp.suggests) 17 | assert max(len(row) for row in keyboard) <= 5 18 | -------------------------------------------------------------------------------- /tests/test_cascade.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from dialogic.cascade import Cascade, DialogTurn 3 | from dialogic.dialog import Context 4 | from dialogic.testing.testing_utils import make_context 5 | 6 | csc = Cascade() 7 | 8 | 9 | @csc.add_handler(priority=0) 10 | def t0(turn: DialogTurn): 11 | turn.response_text = '0' 12 | 13 | 14 | @csc.add_handler(priority=1, intents=['i1']) 15 | def t1(turn: DialogTurn): 16 | turn.response_text = '1' 17 | 18 | 19 | @csc.add_handler(priority=1, intents=['i2']) 20 | def t2(turn: DialogTurn): 21 | turn.response_text = '2' 22 | 23 | 24 | @csc.add_handler(priority=2, intents=['i3']) 25 | def t3(turn: DialogTurn): 26 | turn.response_text = '2' 27 | 28 | 29 | @csc.add_handler(priority=3, stages=['s1']) 30 | def t4(turn: DialogTurn): 31 | turn.response_text = 'stage 1' 32 | 33 | 34 | @csc.postprocessor 35 | def ask_for_tea(turn: DialogTurn): 36 | turn.response_text += '\nDo you want some tea?' 37 | 38 | 39 | @pytest.mark.parametrize('intents,result', [ 40 | ({}, 't0'), 41 | ({'i1': 1.0, 'i2': 0.5}, 't1'), 42 | ({'i2': 0.5}, 't2'), 43 | ({'i1': 1.0, 'i2': 0.5, 'i3': 0.1}, 't3'), 44 | ]) 45 | def test_ranking(intents, result): 46 | turn = DialogTurn(make_context(text='kek'), text='kek', intents=intents) 47 | assert csc(turn) == result 48 | 49 | 50 | def test_ranking_stage(): 51 | ctx = Context(message_text='kek', user_object={'stage': 's1'}, metadata=None) 52 | turn = DialogTurn(ctx, text='kek', intents={'i3': 1}) 53 | assert turn.old_user_object == {'stage': 's1'} 54 | assert turn.stage == 's1' 55 | assert csc(turn) == 't4' 56 | 57 | 58 | def test_postprocess(): 59 | turn = DialogTurn(make_context(text='kek'), text='kek') 60 | turn.response_text = 'The weather is cool.' 61 | # without agenda, no postprocessors are called 62 | turn.release_control() 63 | csc.postprocess(turn) 64 | assert turn.response_text.endswith('cool.') 65 | # without control, no postprocessors are called 66 | turn.take_control() 67 | turn.add_agenda('ask_for_tea') 68 | csc.postprocess(turn) 69 | assert turn.response_text.endswith('cool.') 70 | # with control and agenda, postprocessors are called 71 | turn.release_control() 72 | csc.postprocess(turn) 73 | assert turn.response_text.endswith('tea?') 74 | # after postprocessing, agenda goes away 75 | assert not turn.agenda 76 | -------------------------------------------------------------------------------- /tests/test_controls.py: -------------------------------------------------------------------------------- 1 | import dialogic.nlg.controls as ctrl 2 | 3 | 4 | def test_simple_button(): 5 | data = { 6 | "title": 'button title' 7 | } 8 | button = ctrl.Button(**data) 9 | data['hide'] = True 10 | assert button.to_dict() == data 11 | 12 | 13 | def test_fixed_button_with_url(): 14 | data = { 15 | 'title': 'button title', 16 | 'url': 'https://example.com/', 17 | 'hide': False 18 | } 19 | button = ctrl.Button(**data) 20 | assert button.to_dict() == data 21 | -------------------------------------------------------------------------------- /tests/test_flask_server.py: -------------------------------------------------------------------------------- 1 | import dialogic 2 | 3 | 4 | def test_create_flask_server(): 5 | server = dialogic.server.flask_server.FlaskServer( 6 | connector=dialogic.dialog_connector.DialogConnector( 7 | dialog_manager=dialogic.dialog_manager.BaseDialogManager() 8 | ) 9 | ) 10 | -------------------------------------------------------------------------------- /tests/test_image_manager.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import dialogic 4 | 5 | from dialogic.utils.content_manager import YandexImageAPI 6 | from dialogic.dialog_connector import DialogConnector 7 | 8 | 9 | def test_image_api(): 10 | token = '1234' 11 | skill_id = '5678' 12 | default_image_id = '9101112' 13 | manager = YandexImageAPI(token=token, skill_id=skill_id, default_image_id=default_image_id) 14 | 15 | # test updating 16 | with patch('requests.get') as mock: 17 | mock.return_value.json = lambda: { 18 | 'images': [ 19 | {'id': 'tree', 'origUrl': 'trees.com/tree.jpg', 'size': 100500, 'createdAt': 'today'}, 20 | {'id': 'snake', 'size': 100500, 'createdAt': 'yesterday'}, 21 | ], 22 | 'total': 2 23 | } 24 | manager.update_images() 25 | assert mock.called 26 | args = mock.call_args[1] 27 | assert args['url'] == 'https://dialogs.yandex.net/api/v1/skills/{}/images'.format(skill_id) 28 | assert args['headers'] == {'Authorization': 'OAuth 1234'} 29 | 30 | assert len(manager.url2image) == 1 31 | assert len(manager.id2image) == 2 32 | 33 | # test usage without upload 34 | with patch('requests.post') as mock: 35 | assert manager.get_image_id_by_url('trees.com/tree.jpg') == 'tree' 36 | assert manager.get_image_id_by_url('trees.com/pine.jpg') == default_image_id 37 | assert not mock.called 38 | 39 | # test adding an existing image 40 | with patch('requests.post') as mock: 41 | image = manager.add_image(url='trees.com/tree.jpg') 42 | assert not mock.called 43 | assert image.id == 'tree' 44 | 45 | # test uploading an image 46 | with patch('requests.post') as mock: 47 | oak_url = 'trees.com/oak.jpg' 48 | mock.return_value.json = lambda: { 49 | 'image': { 50 | 'id': 'oak', 51 | 'origUrl': oak_url, 52 | 'size': 100500, 53 | 'createdAt': 'right_now' 54 | } 55 | } 56 | image = manager.add_image(url=oak_url) 57 | assert mock.called 58 | assert mock.call_args[1]['url'] == 'https://dialogs.yandex.net/api/v1/skills/{}/images'.format(skill_id) 59 | assert image.origUrl == oak_url 60 | assert image.id == 'oak' 61 | 62 | assert len(manager.id2image) == 3 63 | assert len(manager.url2image) == 2 64 | 65 | # test uploading an image on the fly 66 | manager.upload_just_in_time = True 67 | with patch('requests.post') as mock: 68 | maple_url = 'trees.com/maple.jpg' 69 | mock.return_value.ok = True 70 | mock.return_value.json = lambda: { 71 | 'image': { 72 | 'id': 'maple', 73 | 'origUrl': maple_url, 74 | 'size': 100500, 75 | 'createdAt': 'right_now' 76 | } 77 | } 78 | assert manager.get_image_id_by_url(maple_url) == 'maple' 79 | assert mock.called 80 | 81 | assert len(manager.id2image) == 4 82 | assert len(manager.url2image) == 3 83 | 84 | # test removing an image 85 | with patch('requests.delete') as mock: 86 | mock.return_value.ok = True 87 | mock.return_value.json = lambda: {'result': 'ok'} 88 | 89 | manager.delete_image('maple') 90 | assert mock.called 91 | assert mock.call_args[1]['url'] == 'https://dialogs.yandex.net/api/v1/skills/{}/images/maple'.format(skill_id) 92 | 93 | assert len(manager.id2image) == 3 94 | assert len(manager.url2image) == 2 95 | 96 | # test automatic conversion of urls to ids 97 | connector = DialogConnector(dialog_manager=None, image_manager=manager) 98 | response = dialogic.dialog.Response('this is an oak', image_url='trees.com/oak.jpg') 99 | result = connector.standardize_output( 100 | source=dialogic.SOURCES.ALICE, 101 | original_message={'version': '1.0'}, 102 | response=response 103 | ) 104 | assert result['response']['card'] == { 105 | 'type': 'BigImage', 106 | 'image_id': 'oak', 107 | 'description': 'this is an oak', 108 | } 109 | -------------------------------------------------------------------------------- /tests/test_managers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avidale/dialogic/c5b0da161da5138ce0bea157a98421b148406970/tests/test_managers/__init__.py -------------------------------------------------------------------------------- /tests/test_managers/faq.yaml: -------------------------------------------------------------------------------- 1 | # FAQ config is a list of Q&A pairs 2 | - q: # a question may contain a list of possible patterns 3 | - hello 4 | - hi 5 | a: # an answer may contain a list of alternatives (to choose at random) 6 | - Hello! 7 | - Nice to meet you! 8 | - Have a good time, human. 9 | s: # there may be one or more suggest buttons 10 | - How are you? 11 | - What can you do? 12 | 13 | - q: How are you # alternatively, the question and/or the answer may consist of a single text 14 | a: I'm fine, thanks 15 | -------------------------------------------------------------------------------- /tests/test_managers/form.yaml: -------------------------------------------------------------------------------- 1 | form_name: 'math_test_form' 2 | 3 | start: 4 | regexp: 'start (the )?test' 5 | # message: Okay, let's start the test! I will ask 3 difficult math questions. Say anything to start. 6 | suggests: 7 | - Start! 8 | - Exit the test 9 | 10 | finish: 11 | message: Okay, thank you for filling the form. We'll tell you the results later. 12 | 13 | fields: 14 | - name: 'name' 15 | question: Please tell me your name 16 | - name: 'year' 17 | question: Now tell me the year of your birth. Four digits, nothing more. 18 | validate_regexp: '^[0-9]{4}$' 19 | validate_message: Please try again. Your answer should be 4 digits. 20 | - name: 'month' 21 | question: Wonderful! Now choose the month of your birth (the first 3 letters). 22 | options: 23 | - jan 24 | - feb 25 | - mar 26 | - apr 27 | - may 28 | - jun 29 | - jul 30 | - aug 31 | - sep 32 | - oct 33 | - nov 34 | - dec 35 | validate_message: The answer should be one of the suggested options - the first 3 letters of a month. 36 | - name: 'day' 37 | question: That's great! Finally, tell me the date of your birth - one or two digits 38 | validate_regexp: '[0123]?\d$' 39 | validate_message: Please try again. Your answer should be a whole number - the day of your birth. 40 | 41 | exit: 42 | suggest: 'Exit the test' 43 | regexp: '(exit|quit)( the)? test' 44 | message: You have chosen to quit the game. If you want to take it again, say "start the test" 45 | -------------------------------------------------------------------------------- /tests/test_managers/intents.yaml: -------------------------------------------------------------------------------- 1 | shalom: 2 | examples: 3 | - shalom 4 | -------------------------------------------------------------------------------- /tests/test_managers/test_faq.py: -------------------------------------------------------------------------------- 1 | import dialogic 2 | 3 | from dialogic.testing.testing_utils import make_context 4 | 5 | 6 | DEFAULT_MESSAGE = 'this is the default message' 7 | 8 | 9 | def test_faq(): 10 | dm = dialogic.dialog_manager.FAQDialogManager( 11 | 'tests/test_managers/faq.yaml', 12 | matcher='cosine', 13 | default_message=DEFAULT_MESSAGE 14 | ) 15 | r1 = dm.respond(make_context(new_session=True)) 16 | assert r1.text == DEFAULT_MESSAGE 17 | first_responses = {dm.respond(make_context(text='hi there', prev_response=r1)).text for i in range(30)} 18 | assert first_responses == {'Hello!', 'Nice to meet you!', 'Have a good time, human.'} 19 | 20 | r2 = dm.respond(make_context(text='hi there', prev_response=r1)) 21 | assert set(r2.suggests) == {'How are you?', 'What can you do?'} 22 | 23 | r3 = dm.respond(make_context(text='how are you', prev_response=r2)) 24 | assert r3.text == "I'm fine, thanks" 25 | 26 | r4 = dm.respond(make_context(text='What can you do?', prev_response=r3)) 27 | assert r4.text == DEFAULT_MESSAGE 28 | -------------------------------------------------------------------------------- /tests/test_managers/test_form.py: -------------------------------------------------------------------------------- 1 | import dialogic 2 | 3 | from dialogic.testing.testing_utils import make_context 4 | 5 | 6 | DEFAULT_MESSAGE = 'this is the default message' 7 | 8 | 9 | class CheckableFormFiller(dialogic.dialog_manager.form_filling.FormFillingDialogManager): 10 | SIGNS = { 11 | 'jan': 'The Goat', 12 | 'feb': 'The Water Bearer', 13 | 'mar': 'The Fishes', 14 | 'apr': 'The Ram', 15 | 'may': 'The Bull', 16 | 'jun': 'The Twins', 17 | 'jul': 'The Crab', 18 | 'aug': 'The Lion', 19 | 'sep': 'The Virgin', 20 | 'oct': 'The Balance', 21 | 'nov': 'The Scorpion', 22 | 'dec': 'The Archer', 23 | } 24 | 25 | def handle_completed_form(self, form, user_object, ctx): 26 | response = dialogic.dialog_manager.base.Response( 27 | text='Thank you, {}! Now we know: you are {} years old and you are probably {}. Lucky you!'.format( 28 | form['fields']['name'], 29 | 2019 - int(form['fields']['year']), 30 | self.SIGNS[form['fields']['month']] 31 | ) 32 | ) 33 | return response 34 | 35 | 36 | def test_form(): 37 | dm = CheckableFormFiller( 38 | 'tests/test_managers/form.yaml', 39 | default_message=DEFAULT_MESSAGE 40 | ) 41 | resp = dm.respond(make_context(new_session=True)) 42 | assert resp.text == DEFAULT_MESSAGE 43 | 44 | for q, a in [ 45 | ('start the test', 'Please tell me your name'), 46 | ('Bob', 'Now tell me the year of your birth. Four digits, nothing more.'), 47 | ('not', 'Please try again. Your answer should be 4 digits.'), 48 | ('0', 'Please try again. Your answer should be 4 digits.'), 49 | ('1999', 'Wonderful! Now choose the month of your birth (the first 3 letters).'), 50 | ('lol', 'The answer should be one of the suggested options - the first 3 letters of a month.'), 51 | ('jan', 'That\'s great! Finally, tell me the date of your birth - one or two digits'), 52 | ('40', 'Please try again. Your answer should be a whole number - the day of your birth.'), 53 | ('02', 'Thank you, Bob! Now we know: you are 20 years old and you are probably The Goat. Lucky you!'), 54 | ('Okay, what\'s next?', DEFAULT_MESSAGE), 55 | ('But really', DEFAULT_MESSAGE) 56 | ]: 57 | resp = dm.respond(make_context(text=q, prev_response=resp)) 58 | assert resp.text == a, 'expected "{}" to be responded by "{}", got "{}" instead'.format(q, a, resp.text) 59 | -------------------------------------------------------------------------------- /tests/test_managers/test_fsa.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import dialogic 3 | 4 | from dialogic.dialog_manager.automaton import AutomatonDialogManager 5 | from dialogic.testing.testing_utils import make_context 6 | 7 | 8 | def test_empty_fsa(): 9 | # FSA cannot be empty, because initial state must exist 10 | with pytest.raises(ValueError): 11 | fsa = AutomatonDialogManager(config={}, matcher='exact') 12 | 13 | 14 | def test_minimal_fsa(): 15 | cfg = { 16 | 'states': { 17 | 'first': {'a': 'hello'} 18 | } 19 | } 20 | fsa = AutomatonDialogManager(config=cfg, matcher='exact') 21 | assert len(fsa.states) == 2 # 'universal' state is always added but it never reached 22 | 23 | ctx = make_context(text='hi', new_session=True) 24 | resp = fsa.try_to_respond(ctx) 25 | assert resp.text == 'hello' 26 | 27 | 28 | @pytest.fixture 29 | def example_fsa(): 30 | cfg = { 31 | 'states': { 32 | 'first': {'a': 'hello', 'next': [{'intent': 'time', 'label': 'second'}]}, 33 | 'second': {'a': '8 pm'}, 34 | 'third': {'q': ['help', 'what can you do'], 'a': 'I am always here to help'}, 35 | 'fourth': {'a': 'hello again', 'next': [{'intent': 'time', 'label': 'second'}]}, 36 | 'fifth': {'q': ['thanks'], 'a': 'thank you', 'restore_prev_state': True}, 37 | 'intro': {'q': ['let\'s introduce ourselves'], 'a': 'I am Max. And you?', 'default_next': 'my_name'}, 38 | 'my_name': {'a': 'Nice to meet you'}, 39 | }, 40 | 'intents': { 41 | 'time': {'regex': '.*time.*'} 42 | }, 43 | 'options': { 44 | 'state_on_new_session': 'fourth', 45 | }, 46 | } 47 | fsa = AutomatonDialogManager(config=cfg, matcher='exact') 48 | return fsa 49 | 50 | 51 | def test_basic_transition(example_fsa): 52 | fsa: AutomatonDialogManager = example_fsa 53 | 54 | # initialize 55 | ctx0 = make_context(text='hi', new_session=True) 56 | resp0 = fsa.try_to_respond(ctx0) 57 | assert resp0.text == 'hello' 58 | 59 | # successful transition 60 | ctx1 = make_context(text='can you tell me the time please', prev_response=resp0) 61 | resp1 = fsa.try_to_respond(ctx1) 62 | assert resp1.text == '8 pm' 63 | 64 | # the same transition from another state is not allowed 65 | ctx2 = make_context(text='can you tell me the time please', prev_response=resp1) 66 | resp2 = fsa.try_to_respond(ctx2) 67 | assert not resp2 68 | 69 | # failed transition: text was not matched 70 | ctx1 = make_context(text='you will not understand me', prev_response=resp0) 71 | resp1 = fsa.try_to_respond(ctx1) 72 | assert not resp1 73 | 74 | # transition from the universal state 75 | ctx1 = make_context(text='help', prev_response=resp0) 76 | resp1 = fsa.try_to_respond(ctx1) 77 | assert resp1.text == 'I am always here to help' 78 | 79 | # new session 80 | ctx2 = make_context(new_session=True, prev_response=resp1) 81 | resp2 = fsa.try_to_respond(ctx2) 82 | assert resp2.text == 'hello again' 83 | 84 | # after transient state, context is restored and previous transition is possible 85 | ctx3 = make_context(prev_response=resp2, text='thanks') 86 | resp3 = fsa.try_to_respond(ctx3) 87 | assert resp3.text == 'thank you' 88 | ctx4 = make_context(prev_response=resp3, text='tell me time now') 89 | resp4 = fsa.try_to_respond(ctx4) 90 | assert resp4.text == '8 pm' 91 | 92 | 93 | def test_default_transition(example_fsa): 94 | fsa: AutomatonDialogManager = example_fsa 95 | 96 | ctx0 = make_context(text='hi', new_session=True) 97 | resp0 = fsa.try_to_respond(ctx0) 98 | 99 | ctx1 = make_context('let\'s introduce ourselves', prev_response=resp0) 100 | resp1 = fsa.try_to_respond(ctx1) 101 | 102 | ctx2 = make_context('Stasy', prev_response=resp1) 103 | resp2 = fsa.try_to_respond(ctx2) 104 | assert resp2.text == 'Nice to meet you' 105 | -------------------------------------------------------------------------------- /tests/test_managers/test_turn_dm.py: -------------------------------------------------------------------------------- 1 | from dialogic.cascade import Cascade, DialogTurn 2 | from dialogic.dialog_manager import TurnDialogManager 3 | from dialogic.testing.testing_utils import make_context 4 | 5 | 6 | def test_turn_dm(): 7 | csc = Cascade() 8 | 9 | @csc.add_handler(priority=0) 10 | def fallback(turn: DialogTurn): 11 | turn.response_text = 'hi' 12 | 13 | @csc.add_handler(priority=1, intents=['shalom']) 14 | def fallback(turn: DialogTurn): 15 | turn.response_text = 'shalom my friend' 16 | 17 | dm = TurnDialogManager(cascade=csc, intents_file='tests/test_managers/intents.yaml') 18 | ctx = make_context('shalom') 19 | resp = dm.respond(ctx) 20 | assert resp.text == 'shalom my friend' 21 | -------------------------------------------------------------------------------- /tests/test_message_logging.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import dialogic 3 | import telebot 4 | 5 | from dialogic.storage.database_utils import get_mongo_or_mock 6 | 7 | 8 | @pytest.fixture 9 | def empty_db(): 10 | database = get_mongo_or_mock() 11 | database.get_collection('message_logs').drop() 12 | return database 13 | 14 | 15 | def test_text_logging_with_connector(empty_db): 16 | database = empty_db 17 | input_message = 'hello bot' 18 | expected_response = 'This is the default message' 19 | dm = dialogic.dialog_manager.BaseDialogManager(default_message=expected_response) 20 | connector = dialogic.dialog_connector.DialogConnector( 21 | dialog_manager=dm, 22 | log_storage=dialogic.storage.message_logging.MongoMessageLogger(database=database) 23 | ) 24 | text_response = connector.respond(message=input_message, source=dialogic.SOURCES.TEXT) 25 | 26 | collection = database.get_collection('message_logs') 27 | logs = list(collection.find()) 28 | assert len(logs) == 2 29 | first, second = logs 30 | 31 | assert first['text'] == input_message 32 | assert second['text'] == expected_response 33 | 34 | assert first['source'] == dialogic.SOURCES.TEXT 35 | assert second['source'] == dialogic.SOURCES.TEXT 36 | 37 | assert first['from_user'] is True 38 | assert second['from_user'] is False 39 | 40 | assert first['data'] == input_message 41 | assert second['data'] == text_response 42 | 43 | 44 | def test_alice_logging_with_connector(empty_db): 45 | input_message = { 46 | "meta": { 47 | "locale": "ru-RU", 48 | "timezone": "Europe/Moscow", 49 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)", 50 | "interfaces": { 51 | "screen": {} 52 | } 53 | }, 54 | "request": { 55 | "command": "привет", 56 | "original_utterance": "привет", 57 | "type": "SimpleUtterance", 58 | "markup": { 59 | "dangerous_context": False 60 | }, 61 | "payload": {}, 62 | "nlu": { 63 | "tokens": ["привет"], 64 | "entities": [] 65 | } 66 | }, 67 | "session": { 68 | "new": False, 69 | "message_id": 4, 70 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 71 | "skill_id": "3ad36498-f5rd-4079-a14b-788652932056", 72 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 73 | }, 74 | "version": "1.0" 75 | } 76 | database = empty_db 77 | expected_response_text = 'This is the default message' 78 | input_message_text = input_message['request']['command'] 79 | dm = dialogic.dialog_manager.BaseDialogManager(default_message=expected_response_text) 80 | connector = dialogic.dialog_connector.DialogConnector( 81 | dialog_manager=dm, 82 | log_storage=dialogic.storage.message_logging.MongoMessageLogger(database=database) 83 | ) 84 | alice_response = connector.respond(message=input_message, source=dialogic.SOURCES.ALICE) 85 | 86 | collection = database.get_collection('message_logs') 87 | logs = list(collection.find()) 88 | assert len(logs) == 2 89 | first, second = logs 90 | 91 | assert first['text'] == input_message_text 92 | assert second['text'] == expected_response_text 93 | 94 | assert first['source'] == dialogic.SOURCES.ALICE 95 | assert second['source'] == dialogic.SOURCES.ALICE 96 | 97 | assert first['from_user'] is True 98 | assert second['from_user'] is False 99 | 100 | assert first['data'] == input_message 101 | assert second['data'] == alice_response 102 | 103 | assert first['request_id'] == second['request_id'] 104 | assert first['request_id'] is not None 105 | 106 | 107 | def test_tg_logging_with_connector(empty_db): 108 | input_message_text = 'привет' 109 | input_message = telebot.types.Message( 110 | message_id=123, 111 | from_user=telebot.types.User(id=456, first_name='Bob', is_bot=False), 112 | chat=telebot.types.Chat(username='Bobby', id=123, type='private'), 113 | date=None, content_type='text', json_string=None, 114 | options={'text': input_message_text} 115 | ) 116 | database = empty_db 117 | expected_response_text = 'This is the default message' 118 | dm = dialogic.dialog_manager.BaseDialogManager(default_message=expected_response_text) 119 | connector = dialogic.dialog_connector.DialogConnector( 120 | dialog_manager=dm, 121 | log_storage=dialogic.storage.message_logging.MongoMessageLogger(database=database) 122 | ) 123 | tg_response = connector.respond(message=input_message, source=dialogic.SOURCES.TELEGRAM) 124 | 125 | if 'reply_markup' in tg_response: 126 | tg_response['reply_markup'] = tg_response['reply_markup'].to_json() 127 | 128 | collection = database.get_collection('message_logs') 129 | logs = list(collection.find()) 130 | assert len(logs) == 2 131 | first, second = logs 132 | 133 | assert first['text'] == input_message_text 134 | assert second['text'] == expected_response_text 135 | 136 | assert first['source'] == dialogic.SOURCES.TELEGRAM 137 | assert second['source'] == dialogic.SOURCES.TELEGRAM 138 | 139 | assert first['from_user'] is True 140 | assert second['from_user'] is False 141 | 142 | assert first['data'] == {'message': str(input_message)} 143 | assert second['data'] == tg_response 144 | 145 | assert first['request_id'] == second['request_id'] 146 | assert first['request_id'] is not None 147 | -------------------------------------------------------------------------------- /tests/test_nlg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avidale/dialogic/c5b0da161da5138ce0bea157a98421b148406970/tests/test_nlg/__init__.py -------------------------------------------------------------------------------- /tests/test_nlg/test_morph.py: -------------------------------------------------------------------------------- 1 | from dialogic.nlg.morph import inflect_case, with_number, human_duration 2 | 3 | 4 | def test_inflect_case(): 5 | assert inflect_case('жук', 'datv') == 'жуку' 6 | 7 | 8 | def test_with_number(): 9 | assert with_number('слон', 0) == '0 слонов' 10 | assert with_number('слон', 1) == '1 слон' 11 | assert with_number('слон', 2) == '2 слона' 12 | assert with_number('слон', 5) == '5 слонов' 13 | 14 | 15 | def test_duration(): 16 | assert human_duration(hours=1, minutes=2, seconds=5) == '1 час 2 минуты 5 секунд' 17 | assert human_duration() == '0 секунд' 18 | assert human_duration(minutes=22) == '22 минуты' 19 | -------------------------------------------------------------------------------- /tests/test_nlg/test_sampling.py: -------------------------------------------------------------------------------- 1 | from dialogic.nlg.sampling import sample 2 | 3 | 4 | def test_sample(): 5 | pattern = '{cat|dog}{ never| always}* says {bow-wow|meow}' 6 | variants = {sample(pattern, rnd=True) for i in range(1000)} 7 | assert 'cat never says bow-wow' in variants 8 | assert 'dog says meow' in variants 9 | assert len(variants) == 2 * 3 * 2 10 | assert len({sample(pattern, rnd=False) for i in range(1000)}) == 1 11 | -------------------------------------------------------------------------------- /tests/test_nlg_markup.py: -------------------------------------------------------------------------------- 1 | from dialogic.nlg.reply_markup import TTSParser 2 | from dialogic.dialog_manager.base import Response 3 | 4 | PARSED_RESPONSE = dict( 5 | markup='This markup be used in Yandex.DialogsYandex Dialogs and other platforms.' 6 | 'Yandex.Dialogs site', 7 | expected_text='This markup be used in Yandex.Dialogs and other platforms.', 8 | expected_voice='This markup be used in Yandex Dialogs and other platforms.', 9 | expected_links=[{'title': 'Yandex.Dialogs site', 'url': 'https://dialogs.yandex.ru/'}], 10 | ) 11 | 12 | 13 | def test_tts_parser(): 14 | parser = TTSParser() 15 | parser.feed(PARSED_RESPONSE['markup']) 16 | assert parser.get_text() == PARSED_RESPONSE['expected_text'] 17 | assert parser.get_voice() == PARSED_RESPONSE['expected_voice'] 18 | assert parser.get_links() == PARSED_RESPONSE['expected_links'] 19 | 20 | 21 | def test_response_set_text(): 22 | resp = Response(text=None).set_text(PARSED_RESPONSE['markup']) 23 | assert resp.text == PARSED_RESPONSE['expected_text'] 24 | assert resp.voice == PARSED_RESPONSE['expected_voice'] 25 | assert resp.links == PARSED_RESPONSE['expected_links'] 26 | -------------------------------------------------------------------------------- /tests/test_nlu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avidale/dialogic/c5b0da161da5138ce0bea157a98421b148406970/tests/test_nlu/__init__.py -------------------------------------------------------------------------------- /tests/test_nlu/expressions.yaml: -------------------------------------------------------------------------------- 1 | NUMBER: '[0-9]+' 2 | _NUMBER_GROUP: '(number )?(?P{{NUMBER}})' 3 | -------------------------------------------------------------------------------- /tests/test_nlu/intents.yaml: -------------------------------------------------------------------------------- 1 | choose: 2 | regex: '((choose|take|set) )?{{_NUMBER_GROUP}}' 3 | -------------------------------------------------------------------------------- /tests/test_nlu/test_matchers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | import math 4 | 5 | 6 | from dialogic.nlu import matchers 7 | 8 | sample_texts = ['привет', 'добрый день', 'сколько времени'] 9 | sample_labels = ['hello', 'hello', 'get_time'] 10 | 11 | NO_MATCH = (None, -math.inf) 12 | 13 | 14 | def test_exact_matcher(): 15 | matcher = matchers.make_matcher('exact') 16 | matcher.fit(sample_texts, sample_labels) 17 | assert matcher.match('привет') == ('hello', 1) 18 | assert matcher.match('приветик') == NO_MATCH 19 | assert matcher.match('добрый день') == ('hello', 1) 20 | assert matcher.match('день добрый') == NO_MATCH 21 | 22 | 23 | def test_jaccard_matcher(): 24 | matcher = matchers.JaccardMatcher(threshold=0.1) 25 | matcher.fit(sample_texts, sample_labels) 26 | assert matcher.match('добрый день') == ('hello', 1) 27 | assert matcher.match('добрый вечер') == ('hello', 1/3) 28 | assert matcher.match('добрый') == ('hello', 1/2) 29 | 30 | 31 | def test_tfidf_matcher(): 32 | new_texts = ['добрый день', 'доброй ночи', 'добрый вечер', 'доброе утро', 'животное хомяк', 'животное пингвин'] 33 | new_labels = ['hello', 'hello', 'hello', 'hello', 'animal', 'animal'] 34 | matcher = matchers.TFIDFMatcher(threshold=0.25, text_normalization=matchers.TextNormalization.FAST_LEMMATIZE) 35 | matcher.fit(new_texts, new_labels) 36 | assert matcher.match('добрый день') == ('hello', 1) 37 | assert matcher.match('добрый упоротыш') == NO_MATCH 38 | assert matcher.match('добрый хомяк')[0] == 'animal' 39 | assert matcher.match('животное собака')[0] == 'animal' 40 | 41 | 42 | @pytest.mark.parametrize('weights', [None, (1, 1), (0.2, 0.8)]) 43 | def test_average_matcher(weights): 44 | matcher = matchers.WeightedAverageMatcher( 45 | matchers=[matchers.make_matcher('exact'), matchers.JaccardMatcher()], 46 | threshold=0.1, weights=weights, 47 | ) 48 | matcher.fit(sample_texts, sample_labels) 49 | weights = weights or (0.5, 0.5) 50 | w0, w1 = weights[0] / sum(weights), weights[1] / sum(weights) 51 | assert matcher.match('добрый день') == ('hello', 1) 52 | assert matcher.match('добрый вечер') == ('hello', 0 * w0 + 1/3 * w1) 53 | assert matcher.match('добрый') == ('hello', 0 * w0 + 1/2 * w1) 54 | 55 | 56 | def test_max_matcher(): 57 | matcher = matchers.MaxMatcher( 58 | matchers=[matchers.make_matcher('exact'), matchers.JaccardMatcher()], 59 | threshold=0.1, 60 | ) 61 | matcher.fit(sample_texts, sample_labels) 62 | assert matcher.match('добрый день') == ('hello', 1) 63 | assert matcher.match('добрый вечер') == ('hello', 1/3) 64 | assert matcher.match('добрый') == ('hello', 1/2) 65 | 66 | 67 | class PrefixModel: 68 | """ Its main goal is to mimic interface of scikit-learn models """ 69 | def __init__(self): 70 | self.classes_ = [] 71 | self._x = [] 72 | self._y = [] 73 | 74 | def fit(self, X, y): 75 | self.classes_ = sorted(set(y)) 76 | self._y2i = {val: i for i, val in enumerate(self.classes_)} 77 | self._x = X 78 | self._y = y 79 | 80 | def longest_common_prefix(self, lhs, rhs): 81 | if lhs == rhs == '': 82 | return 0.0 83 | result = 0 84 | for left, right in zip(lhs, rhs): 85 | if left == right: 86 | result += 1 87 | else: 88 | break 89 | return result * 2.0 / (len(lhs) + len(rhs)) 90 | 91 | def predict_proba(self, X): 92 | scores = np.zeros((len(X), len(self.classes_)), dtype=np.float) 93 | for i, text in enumerate(X): 94 | for x, y in zip(self._x, self._y): 95 | scores[i, self._y2i[y]] = max(scores[i, self._y2i[y]], self.longest_common_prefix(x, text)) 96 | return scores 97 | 98 | 99 | def test_model_matcher(): 100 | matcher = matchers.ModelBasedMatcher(model=PrefixModel()) 101 | matcher.fit(sample_texts, sample_labels) 102 | assert matcher.match('добрый день') == ('hello', 1) 103 | assert matcher.match('добрый д') == ('hello', 8 / ((8 + 11) / 2)) 104 | assert matcher.match('добрый') == ('hello', 6 / ((6 + 11) / 2)) 105 | 106 | 107 | @pytest.mark.parametrize('matcher_class', [matchers.W2VMatcher, matchers.WMDMatcher]) 108 | def test_vectorized_matcher(matcher_class): 109 | w2v = { 110 | k: np.array(v) for k, v in 111 | { 112 | 'привет': [1, 0, 0], 113 | 'добрый': [0.5, 0.5, 0], 114 | 'злой': [0.45, 0.55, 0], 115 | 'день': [0.1, 0.2, 0.7], 116 | 'ночь': [0.0, 0.4, 0.7], 117 | 'сколько': [0.0, 0.1, 0.9], 118 | 'времени': [0.0, 0.5, 0.5], 119 | }.items() 120 | } 121 | matcher = matcher_class(w2v=w2v) 122 | matcher.fit(sample_texts, sample_labels) 123 | assert matcher.match('времени сколько') == ('get_time', 1) 124 | assert matcher.match('абракадабра') == NO_MATCH 125 | label, score = matcher.match('злой ночь') 126 | assert label == 'hello' 127 | assert 0.95 < score < 0.99 128 | 129 | 130 | def test_scores_aggregation(): 131 | matcher = matchers.JaccardMatcher(threshold=0.1) 132 | matcher.fit(sample_texts, sample_labels) 133 | assert matcher.aggregate_scores('добрый день') == {'hello': 1} 134 | assert matcher.aggregate_scores('добрый день', use_threshold=False) == {'hello': 1, 'get_time': 0} 135 | assert matcher.aggregate_scores('привет сколько времени') == {'hello': 1/3, 'get_time': 2/3} 136 | 137 | 138 | def test_regex_matcher(): 139 | more_texts = sample_texts + ['.*врем.*'] 140 | more_labels = sample_labels + ['get_time'] 141 | 142 | matcher = matchers.RegexMatcher(add_end=False) 143 | matcher.fit(more_texts, more_labels) 144 | assert matcher.aggregate_scores('привет мир') == {'hello': 1} 145 | assert matcher.aggregate_scores('привет расскажи время') == {'get_time': 1, 'hello': 1} 146 | 147 | matcher = matchers.RegexMatcher(add_end=True) 148 | matcher.fit(more_texts, more_labels) 149 | assert matcher.aggregate_scores('привет мир') == {} 150 | assert matcher.aggregate_scores('расскажи время') == {'get_time': 1} 151 | assert matcher.aggregate_scores('привет расскажи время') == {'get_time': 1} 152 | 153 | 154 | def test_joint_matcher_with_regex(): 155 | intents = { 156 | 'a': {'examples': ['an a'], 'regexp': 'a+'} 157 | } 158 | jm = matchers.make_matcher_with_regex(base_matcher=matchers.ExactMatcher(), intents=intents) 159 | assert jm.aggregate_scores('an a') == {'a': 1} 160 | assert jm.aggregate_scores('aaaa') == {'a': 1} 161 | 162 | 163 | def test_edlib_matcher(): 164 | matcher = matchers.EdlibMatcher() 165 | matcher.fit(sample_texts, sample_labels) 166 | assert matcher.match('сколько времени') == ('get_time', 1) 167 | assert matcher.match('мой дорогой сколько времени давно тебя не видел') == ('get_time', 1) 168 | assert matcher.match('мой дорогой сколько время давно тебя не видел') == ('get_time', 0.8) 169 | 170 | matcher = matchers.EdlibMatcher(ignore_suffix=True, ignore_prefix=False) 171 | matcher.fit(sample_texts, sample_labels) 172 | assert matcher.match('сколько времени ыыыыы') == ('get_time', 1) 173 | assert matcher.match('ыыыыы сколько времени') == ('get_time', 0.6) 174 | 175 | matcher = matchers.EdlibMatcher(ignore_suffix=False, ignore_prefix=True) 176 | matcher.fit(sample_texts, sample_labels) 177 | assert matcher.match('сколько времени ыыыыы') == ('get_time', 0.6) 178 | assert matcher.match('ыыыыы сколько времени') == ('get_time', 1) 179 | 180 | matcher = matchers.EdlibMatcher(ignore_suffix=False, ignore_prefix=False) 181 | matcher.fit(sample_texts, sample_labels) 182 | assert matcher.match('сколько времени ыыыыы') == ('get_time', 0.6) 183 | assert matcher.match('ыыыыы сколько времени') == ('get_time', 0.6) 184 | -------------------------------------------------------------------------------- /tests/test_nlu/test_regex_expander.py: -------------------------------------------------------------------------------- 1 | from dialogic.nlu.matchers import make_matcher_with_regex, JaccardMatcher 2 | from dialogic.nlu.regex_expander import load_intents_with_replacement 3 | 4 | 5 | def test_expander(): 6 | intents = load_intents_with_replacement( 7 | 'tests/test_nlu/intents.yaml', 8 | 'tests/test_nlu/expressions.yaml' 9 | ) 10 | matcher = make_matcher_with_regex(JaccardMatcher(), intents=intents) 11 | assert matcher.match('take number 10') 12 | -------------------------------------------------------------------------------- /tests/test_phrase.py: -------------------------------------------------------------------------------- 1 | import dialogic 2 | 3 | from dialogic.dialog.phrase import Phrase 4 | 5 | 6 | def test_phrase_from_string(): 7 | p1 = Phrase.from_object('hello') 8 | assert p1.texts == ['hello'] 9 | resp = p1.render() 10 | assert resp.text == resp.voice == 'hello' 11 | 12 | p2 = Phrase.from_object('CiaoЧао') 13 | resp = p2.render() 14 | assert resp.text == 'Ciao' 15 | assert resp.voice == 'Чао' 16 | 17 | 18 | def test_phrase_from_dict(): 19 | p1 = Phrase.from_object({ 20 | 'name': 'greeting', 21 | 'text': 'hello', 22 | 'exit': True, 23 | 'suggests': ['Ciao', 'Bye'], 24 | }) 25 | resp = p1.render(additional_suggests=['Auf Wiedersehen']) 26 | assert resp.commands == [dialogic.dialog.names.COMMANDS.EXIT] 27 | assert resp.suggests == ['Ciao', 'Bye', 'Auf Wiedersehen'] 28 | 29 | 30 | def test_random_phrase(): 31 | p1 = Phrase(name='hi', text=['hello', 'hi']) 32 | 33 | assert len({p1.render(seed=1).text for i in range(100)}) == 1 34 | assert len({p1.render().text for i in range(100)}) == 2 35 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from dialogic.utils.collections import make_unique, sample_at_most 2 | 3 | import random 4 | 5 | 6 | def test_make_unique(): 7 | random.seed(42) 8 | for n in range(100): 9 | x = [random.randrange(10) for i in range(n)] 10 | y = make_unique(x) 11 | seen = set() 12 | for element in x: 13 | if element not in seen: 14 | assert y[len(seen)] == element 15 | seen.add(element) 16 | 17 | 18 | def test_sample_at_most(): 19 | random.seed(42) 20 | for n in range(100): 21 | for m in range(100): 22 | x = [random.randrange(10) for i in range(n)] 23 | y = sample_at_most(x, m) 24 | assert len(set(y)) == len(y) 25 | assert len(y) == min(m, len(set(x))) 26 | assert not set(y).difference(set(x)) 27 | -------------------------------------------------------------------------------- /tests/test_vk_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avidale/dialogic/c5b0da161da5138ce0bea157a98421b148406970/tests/test_vk_api/__init__.py -------------------------------------------------------------------------------- /tests/test_vk_api/test_methods.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock 2 | 3 | from dialogic.interfaces.vk import VKBot, VKMessage 4 | 5 | 6 | @patch('requests.post') 7 | def test_send_message(mock_post: MagicMock): 8 | mock_post.return_value.status_code = 200 9 | mock_post.return_value.json = lambda: {} 10 | 11 | bot = VKBot(token='12345', group_id=12345) 12 | bot.send_message(peer_id=666, text='hello', keyboard={'buttons': [[{'action': {'type': 'text', 'label': 'yay'}}]]}) 13 | assert mock_post.called 14 | args, kwargs = mock_post.call_args 15 | assert kwargs['url'].endswith('messages.send') 16 | assert kwargs['data']['peer_id'] == 666 17 | assert kwargs['data']['message'] == 'hello' 18 | assert kwargs['data']['access_token'] == '12345' 19 | assert 'group_id' not in kwargs['data'] 20 | assert isinstance(kwargs['data']['keyboard'], str) 21 | 22 | 23 | def test_apply_handlers(): 24 | bot = VKBot(token='12345', group_id=12345) 25 | processed1 = [] 26 | processed2 = [] 27 | processed3 = [] 28 | processed4 = [] 29 | 30 | @bot.message_handler(regexp='hello') 31 | def handle1(message: VKMessage): 32 | processed1.append(message) 33 | 34 | @bot.message_handler(types=['strange_type']) 35 | def handle2(message: VKMessage): 36 | processed2.append(message) 37 | 38 | @bot.message_handler(func=lambda x: len(x['object']['message']['text']) == 3) 39 | def handle3(message: VKMessage): 40 | processed3.append(message) 41 | 42 | @bot.message_handler() 43 | def handle4(message: VKMessage): 44 | processed4.append(message) 45 | 46 | assert len(bot.message_handlers) == 4 47 | 48 | bot.process_new_updates([{'type': 'message_new', 'object': {'message': {'text': 'hello bot'}}}]) 49 | assert len(processed1) == 1 50 | assert len(processed2) == 0 51 | assert processed1[0].text == 'hello bot' 52 | 53 | bot.process_new_updates([{'type': 'strange_type', 'object': {'message': {'text': 'hello bot'}}}]) 54 | assert len(processed1) == 1 55 | assert len(processed2) == 1 56 | 57 | bot.process_new_updates([{'type': 'message_new', 'object': {'message': {'text': 'wow'}}}]) 58 | assert len(processed3) == 1 59 | 60 | bot.process_new_updates([{'type': 'message_new', 'object': {'message': {'text': 'fallback'}}}]) 61 | assert len(processed4) == 1 62 | 63 | 64 | def test_webhook_processing(): 65 | bot = VKBot(token='12345', group_id=12345) 66 | bot.webhook_key = 'secret' 67 | assert bot.process_webhook_data({'type': 'confirmation', 'group_id': 12345}) == ('secret', 200) 68 | 69 | messages = [] 70 | 71 | @bot.message_handler() 72 | def handle(message): 73 | messages.append(message) 74 | 75 | new_message = {'type': 'message_new', 'object': {'message': {'text': 'hello bot'}}} 76 | assert bot.process_webhook_data(new_message) == ('ok', 200) 77 | assert messages[0].text == 'hello bot' 78 | 79 | 80 | def test_polling(): 81 | bot = VKBot(token='12345', group_id=12345) 82 | 83 | # test setting polling server 84 | assert bot._polling_server is None 85 | with patch('requests.get') as mock_get: 86 | mock_get.return_value.status_code = 200 87 | mock_get.return_value.json = lambda: {'response': {'server': 'abcd', 'key': 'xyz', 'ts': 23}} 88 | bot.set_polling_server() 89 | assert mock_get.called 90 | assert mock_get.call_args[1]['url'].endswith('groups.getLongPollServer') 91 | assert bot._polling_server == 'abcd' 92 | 93 | # test actually polling 94 | with patch('requests.get') as mock_get: 95 | mock_get.return_value.status_code = 200 96 | mock_get.return_value.json = lambda: {'updates': ['i am an update'], 'ts': 38} 97 | assert bot.retrieve_updates() == ['i am an update'] 98 | assert mock_get.called 99 | assert mock_get.call_args[1]['url'] == 'abcd' 100 | assert mock_get.call_args[1]['params']['key'] == 'xyz' 101 | assert mock_get.call_args[1]['params']['ts'] == 23 102 | assert bot._polling_ts == 38 103 | 104 | 105 | def test_webhook_remove(): 106 | bot = VKBot(token='12345', group_id=12345) 107 | with patch('requests.get') as mock_get: 108 | mock_get.return_value.status_code = 200 109 | mock_get.return_value.json = lambda: {'response': {'items': [{'id': 17}, {'id': 18}]}} 110 | bot.remove_webhook() 111 | assert mock_get.call_count == 3 112 | assert mock_get.call_args_list[0][1]['url'].endswith('groups.getCallbackServers') 113 | for call_args, item_id in zip(mock_get.call_args_list[1:], [17, 18]): 114 | assert call_args[1]['url'].endswith('groups.deleteCallbackServer') 115 | assert call_args[1]['params']['server_id'] == item_id 116 | 117 | 118 | def test_webhook_set(): 119 | bot = VKBot(token='12345', group_id=12345) 120 | assert bot.webhook_key is None 121 | with patch('requests.get') as mock_get: 122 | mock_get.return_value.status_code = 200 123 | mock_get.return_value.json = lambda: {'response': {'code': '777', 'server_id': 13}} 124 | bot.set_webhook(url='localhost:15777', remove_old=False) 125 | assert bot.webhook_key == '777' 126 | assert mock_get.call_count == 3 127 | assert mock_get.call_args_list[0][1]['url'].endswith('groups.getCallbackConfirmationCode') 128 | 129 | assert mock_get.call_args_list[1][1]['url'].endswith('groups.addCallbackServer') 130 | assert mock_get.call_args_list[1][1]['params']['secret_key'] == '777' 131 | assert mock_get.call_args_list[1][1]['params']['url'] == 'localhost:15777' 132 | 133 | assert mock_get.call_args_list[2][1]['url'].endswith('groups.setCallbackSettings') 134 | assert mock_get.call_args_list[2][1]['params']['server_id'] == 13 135 | -------------------------------------------------------------------------------- /tests/test_yandex_api/test_request.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import dialogic.interfaces.yandex as yandex 4 | 5 | 6 | @pytest.fixture 7 | def example_request(): 8 | result = { 9 | "meta": { 10 | "locale": "ru-RU", 11 | "timezone": "Europe/Moscow", 12 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)", 13 | "interfaces": { 14 | "screen": {}, 15 | "account_linking": {} 16 | } 17 | }, 18 | "request": { 19 | "command": "закажи пиццу на улицу льва толстого 16 на завтра", 20 | "original_utterance": "закажи пиццу на улицу льва толстого, 16 на завтра", 21 | "type": "SimpleUtterance", 22 | "markup": { 23 | "dangerous_context": True 24 | }, 25 | "payload": {}, 26 | "nlu": { 27 | "tokens": [ 28 | "закажи", 29 | "пиццу", 30 | "на", 31 | "льва", 32 | "толстого", 33 | "16", 34 | "на", 35 | "завтра" 36 | ], 37 | "entities": [ 38 | { 39 | "tokens": { 40 | "start": 2, 41 | "end": 6 42 | }, 43 | "type": "YANDEX.GEO", 44 | "value": { 45 | "house_number": "16", 46 | "street": "льва толстого" 47 | } 48 | }, 49 | { 50 | "tokens": { 51 | "start": 3, 52 | "end": 5 53 | }, 54 | "type": "YANDEX.FIO", 55 | "value": { 56 | "first_name": "лев", 57 | "last_name": "толстой" 58 | } 59 | }, 60 | { 61 | "tokens": { 62 | "start": 5, 63 | "end": 6 64 | }, 65 | "type": "YANDEX.NUMBER", 66 | "value": 16 67 | }, 68 | { 69 | "tokens": { 70 | "start": 6, 71 | "end": 8 72 | }, 73 | "type": "YANDEX.DATETIME", 74 | "value": { 75 | "day": 1, 76 | "day_is_relative": True 77 | } 78 | } 79 | ] 80 | } 81 | }, 82 | "session": { 83 | "message_id": 0, 84 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 85 | "skill_id": "3ad36498-f5rd-4079-a14b-788652932056", 86 | "user_id": "47C73714B580ED2469056E71081159529FFC676A4E5B059D629A819E857DC2F8", 87 | "user": { 88 | "user_id": "6C91DA5198D1758C6A9F63A7C5CDDF09359F683B13A18A151FBF4C8B092BB0C2", 89 | "access_token": "AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT" 90 | }, 91 | "application": { 92 | "application_id": "47C73714B580ED2469056E71081159529FFC676A4E5B059D629A819E857DC2F8" 93 | }, 94 | "new": True, 95 | }, 96 | "version": "1.0" 97 | } 98 | return result 99 | 100 | 101 | def test_deserialization(example_request): 102 | req = yandex.request.YandexRequest.from_dict(example_request) 103 | assert 'пиццу' in req.request.command 104 | assert any(e.type == yandex.request.ENTITY_TYPES.YANDEX_FIO for e in req.request.nlu.entities) 105 | assert(any(e.tokens.end == 8 for e in req.request.nlu.entities)) 106 | req_dict = req.to_dict() 107 | # todo: ignoring Nones, assert req_dict == req 108 | 109 | 110 | def test_intents(): 111 | raw_req = { 112 | "command": "включи свет на кухне, пожалуйста", 113 | "nlu": { 114 | "intents": { 115 | "turn.on": { 116 | "slots": { 117 | "what": { 118 | "type": "YANDEX.STRING", 119 | "value": "свет" 120 | }, 121 | "where": { 122 | "type": "YANDEX.STRING", 123 | "value": "на кухне" 124 | } 125 | } 126 | } 127 | } 128 | } 129 | } 130 | req = yandex.request.Request.from_dict(raw_req) 131 | assert "turn.on" in req.nlu.intents 132 | assert req.nlu.intents['turn.on'].slots['what'].type == "YANDEX.STRING" 133 | 134 | 135 | def test_state_extraction(): 136 | raw_req = { 137 | "meta": { 138 | "locale": "ru-RU", 139 | "timezone": "Europe/Moscow", 140 | "client_id": "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)", 141 | "interfaces": { 142 | "screen": {} 143 | } 144 | }, 145 | "request": { 146 | "command": "привет", 147 | "original_utterance": "привет", 148 | "type": "SimpleUtterance", 149 | "markup": { 150 | "dangerous_context": True 151 | }, 152 | "payload": {}, 153 | "nlu": { 154 | "tokens": [ 155 | "привет" 156 | ], 157 | "entities": [ 158 | ] 159 | } 160 | }, 161 | "session": { 162 | "new": True, 163 | "message_id": 4, 164 | "session_id": "2eac4854-fce721f3-b845abba-20d60", 165 | "skill_id": "3ad36498-f5rd-4079-a14b-788652932056", 166 | "user_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC", 167 | "application": { 168 | "application_id": "AC9WC3DF6FCE052E45A4566A48E6B7193774B84814CE49A922E163B8B29881DC" 169 | }, 170 | }, 171 | "state": { 172 | "session": { 173 | "value": 10 174 | }, 175 | "user": { 176 | "value": 42 177 | } 178 | }, 179 | "version": "1.0" 180 | } 181 | req = yandex.YandexRequest.from_dict(raw_req) 182 | assert req.state.session and req.state.session['value'] == 10 183 | assert req.state.user and req.state.user['value'] == 42 184 | -------------------------------------------------------------------------------- /tests/test_yandex_api/test_response.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import dialogic.interfaces.yandex as yandex 4 | 5 | 6 | @pytest.fixture 7 | def example_response(): 8 | result = { 9 | "response": { 10 | "text": "Здравствуйте! Это мы, хороводоведы.", 11 | "tts": "Здравствуйте! Это мы, хоров+одо в+еды.", 12 | "buttons": [ 13 | { 14 | "title": "Надпись на кнопке", 15 | "payload": {}, 16 | "url": "https://example.com/", 17 | "hide": True 18 | } 19 | ], 20 | "end_session": False 21 | }, 22 | "version": "1.0" 23 | } 24 | return result 25 | 26 | 27 | def test_deserialization(example_response): 28 | res = yandex.response.YandexResponse.from_dict(example_response) 29 | assert res.response.buttons[0].hide is True 30 | 31 | 32 | def test_big_image(): 33 | raw_resp = { 34 | "response": { 35 | "text": "Здравствуйте! Это мы, хороводоведы.", 36 | "tts": "Здравствуйте! Это мы, хоров+одо в+еды.", 37 | "card": { 38 | "type": "BigImage", 39 | "image_id": "1027858/46r960da47f60207e924", 40 | "title": "Заголовок для изображения", 41 | "description": "Описание изображения.", 42 | "button": { 43 | "text": "Надпись на кнопке", 44 | "url": "http://example.com/", 45 | "payload": {} 46 | } 47 | }, 48 | "buttons": [ 49 | { 50 | "title": "Надпись на кнопке", 51 | "payload": {}, 52 | "url": "https://example.com/", 53 | "hide": True 54 | } 55 | ], 56 | "end_session": False 57 | }, 58 | "version": "1.0" 59 | } 60 | resp = yandex.YandexResponse.from_dict(raw_resp) 61 | assert resp.response.card 62 | assert resp.response.card.type == yandex.response.CARD_TYPES.BIG_IMAGE 63 | assert resp.response.card.button.url == "http://example.com/" 64 | 65 | 66 | def test_items_list(): 67 | raw_resp = { 68 | "response": { 69 | "text": "Здравствуйте! Это мы, хороводоведы.", 70 | "tts": "Здравствуйте! Это мы, хоров+одо в+еды.", 71 | "card": { 72 | "type": "ItemsList", 73 | "header": { 74 | "text": "Заголовок галереи изображений", 75 | }, 76 | "items": [ 77 | { 78 | "image_id": "", 79 | "title": "Заголовок для изображения.", 80 | "description": "Описание изображения.", 81 | "button": { 82 | "text": "Надпись на кнопке", 83 | "url": "http://example.com/", 84 | "payload": {} 85 | } 86 | } 87 | ], 88 | "footer": { 89 | "text": "Текст блока под изображением.", 90 | "button": { 91 | "text": "Надпись на кнопке", 92 | "url": "https://example.com/", 93 | "payload": {} 94 | } 95 | } 96 | }, 97 | "buttons": [ 98 | { 99 | "title": "Надпись на кнопке", 100 | "payload": {}, 101 | "url": "https://example.com/", 102 | "hide": True 103 | } 104 | ], 105 | "end_session": False 106 | }, 107 | "version": "1.0" 108 | } 109 | resp = yandex.YandexResponse.from_dict(raw_resp) 110 | assert resp.response.card 111 | assert resp.response.card.type == yandex.response.CARD_TYPES.ITEMS_LIST 112 | assert resp.response.card.header.text == 'Заголовок галереи изображений' 113 | assert resp.response.card.footer.button.text == 'Надпись на кнопке' 114 | assert resp.response.card.items[0].image_id == '' 115 | assert resp.response.card.items[0].button.url == 'http://example.com/' 116 | --------------------------------------------------------------------------------