├── .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 | [](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 | [](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 |
--------------------------------------------------------------------------------