diff --git a/CHANGES.md b/CHANGES.md index 5fdfc22..3797230 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,15 @@ +## v4.0.0 + +After a lot of internal dogfooding and bot building, we decided to change the API in a backwards-incompatible way. The changes are described below and aim to simplify user code and accommodate upcoming features. + +See `./examples` to see how to use the new API. + +### Breaking changes + +- `say` renamed to `send` to reflect that it deals with more than just text +- Removed built-in actions `merge` and `error` +- Actions signature simplified with `request` and `response` arguments +- INFO level replaces LOG level - adding verbose option for `message`, `converse` and `run_actions` ## v3.5 diff --git a/README.md b/README.md index 286f1d5..e03690f 100644 --- a/README.md +++ b/README.md @@ -37,62 +37,30 @@ You can target a specific version by setting the env variable `WIT_API_VERSION`. ### Wit class The Wit constructor takes the following parameters: -* `token` - the access token of your Wit instance -* `actions` - the dictionary with your actions +* `access_token` - the access token of your Wit instance +* `actions` - (optional if you only use `message()`) the dictionary with your actions `actions` has action names as keys and action implementations as values. -You need to provide at least an implementation for the special actions `say`, `merge` and `error`. -A minimal `actions` dict looks like this: -```python -def say(session_id, context, msg): - print(msg) - -def merge(session_id, context, entities, msg): - return context - -def error(session_id, context, e): - print(str(e)) - -actions = { - 'say': say, - 'merge': merge, - 'error': error, -} -``` +A minimal example looks like this: -A custom action takes the following parameters: -* `session_id` - a unique identifier describing the user session -* `context` - the dictionary representing the session state - -Example: ```python from wit import Wit -client = Wit(token, actions) -``` -### Logging +def send(request, response): + print('Sending to user...', response['text']) +def my_action(request): + print('Received from user...', request['text']) -Default logging is to `STDOUT` with `INFO` level. - -You can set your logging level as follows: -``` python -from wit import Wit -import logging -client = Wit(token, actions) -client.logger.setLevel(logging.WARNING) -``` +actions = { + 'send': send, + 'my_action': my_action, +} -You can also specify a custom logger object in the Wit constructor: -``` python -from wit import Wit -client = Wit(token, actions, logger=custom_logger) +client = Wit(access_token=access_token, actions=actions) ``` -See the [logging module](https://docs.python.org/2/library/logging.html) and -[logging.config](https://docs.python.org/2/library/logging.config.html#module-logging.config) docs for more information. - -### message +### .message() The Wit [message API](https://wit.ai/docs/http/20160330#get-intent-via-text-link). @@ -106,7 +74,7 @@ resp = client.message('what is the weather in London?') print('Yay, got Wit.ai response: ' + str(resp)) ``` -### run_actions +### .run_actions() A higher-level method to the Wit converse API. @@ -127,7 +95,7 @@ context2 = client.run_actions(session_id, 'and in Brussels?', context1) print('The session state is now: ' + str(context2)) ``` -### converse +### .converse() The low-level Wit [converse API](https://wit.ai/docs/http/20160330#converse-link). @@ -145,7 +113,7 @@ print('Yay, got Wit.ai response: ' + str(resp)) See the [docs](https://wit.ai/docs) for more information. -### interactive +### .interactive() Starts an interactive conversation with your bot. @@ -155,3 +123,24 @@ client.interactive() ``` See the [docs](https://wit.ai/docs) for more information. + +### Logging + +Default logging is to `STDOUT` with `INFO` level. + +You can set your logging level as follows: +``` python +from wit import Wit +import logging +client = Wit(token, actions) +client.logger.setLevel(logging.WARNING) +``` + +You can also specify a custom logger object in the Wit constructor: +``` python +from wit import Wit +client = Wit(access_token=access_token, actions=actions, logger=custom_logger) +``` + +See the [logging module](https://docs.python.org/2/library/logging.html) and +[logging.config](https://docs.python.org/2/library/logging.config.html#module-logging.config) docs for more information. diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..1a6221b --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,17 @@ +import sys +from wit import Wit + +if len(sys.argv) != 2: + print('usage: python ' + sys.argv[0] + ' ') + exit(1) +access_token = sys.argv[1] + +def send(request, response): + print(response['text']) + +actions = { + 'send': send, +} + +client = Wit(access_token=access_token, actions=actions) +client.interactive() diff --git a/examples/joke.py b/examples/joke.py index fcc8d95..292b795 100644 --- a/examples/joke.py +++ b/examples/joke.py @@ -2,21 +2,13 @@ import sys from wit import Wit -# Joke example -# See https://wit.ai/patapizza/example-joke - if len(sys.argv) != 2: - print("usage: python examples/joke.py ") + print('usage: python ' + sys.argv[0] + ' ') exit(1) access_token = sys.argv[1] -def first_entity_value(entities, entity): - if entity not in entities: - return None - val = entities[entity][0]['value'] - if not val: - return None - return val['value'] if isinstance(val, dict) else val +# Joke example +# See https://wit.ai/patapizza/example-joke all_jokes = { 'chuck': [ @@ -32,10 +24,21 @@ def first_entity_value(entities, entity): ], } -def say(session_id, context, msg): - print(msg) +def first_entity_value(entities, entity): + if entity not in entities: + return None + val = entities[entity][0]['value'] + if not val: + return None + return val['value'] if isinstance(val, dict) else val + +def send(request, response): + print(response['text']) + +def merge(request): + context = request['context'] + entities = request['entities'] -def merge(session_id, context, entities, msg): if 'joke' in context: del context['joke'] category = first_entity_value(entities, 'category') @@ -48,22 +51,19 @@ def merge(session_id, context, entities, msg): del context['ack'] return context -def error(session_id, context, e): - print(str(e)) +def select_joke(request): + context = request['context'] -def select_joke(session_id, context): jokes = all_jokes[context['cat'] or 'default'] shuffle(jokes) context['joke'] = jokes[0] return context actions = { - 'say': say, + 'send': send, 'merge': merge, - 'error': error, 'select-joke': select_joke, } -client = Wit(access_token, actions) -session_id = 'my-user-id-42' -client.run_actions(session_id, 'tell me a joke about tech', {}) +client = Wit(access_token=access_token, actions=actions) +client.interactive() diff --git a/examples/quickstart.py b/examples/quickstart.py index 8ec8a89..3e0303c 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -1,14 +1,14 @@ import sys from wit import Wit -# Quickstart example -# See https://wit.ai/l5t/Quickstart - if len(sys.argv) != 2: - print("usage: python examples/quickstart.py ") + print('usage: python ' + sys.argv[0] + ' ') exit(1) access_token = sys.argv[1] +# Quickstart example +# See https://wit.ai/ar7hur/Quickstart + def first_entity_value(entities, entity): if entity not in entities: return None @@ -17,28 +17,27 @@ def first_entity_value(entities, entity): return None return val['value'] if isinstance(val, dict) else val -def say(session_id, context, msg): - print(msg) +def send(request, response): + print(response['text']) + +def get_forecast(request): + context = request['context'] + entities = request['entities'] -def merge(session_id, context, entities, msg): loc = first_entity_value(entities, 'location') if loc: - context['loc'] = loc - return context - -def error(session_id, context, e): - print(str(e)) + context['forecast'] = 'sunny' + else: + context['missingLocation'] = True + if context.get('forecast') is not None: + del context['forecast'] -def fetch_weather(session_id, context): - context['forecast'] = 'sunny' return context actions = { - 'say': say, - 'merge': merge, - 'error': error, - 'fetch-weather': fetch_weather, + 'send': send, + 'getForecast': get_forecast, } -client = Wit(access_token, actions) +client = Wit(access_token=access_token, actions=actions) client.interactive() diff --git a/examples/template.py b/examples/template.py deleted file mode 100644 index d1d14e5..0000000 --- a/examples/template.py +++ /dev/null @@ -1,26 +0,0 @@ -import sys -from wit import Wit - -if len(sys.argv) != 2: - print("usage: python examples/template.py ") - exit(1) -access_token = sys.argv[1] - -def say(session_id, context, msg): - print(msg) - -def merge(session_id, context, entities, msg): - return context - -def error(session_id, context, e): - print(str(e)) - -actions = { - 'say': say, - 'merge': merge, - 'error': error, -} -client = Wit(access_token, actions) - -session_id = 'my-user-id-42' -client.run_actions(session_id, 'your message', {}) diff --git a/setup.py b/setup.py index 4303393..9a4da68 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ setup( name='wit', - version='3.5.0', + version='4.0.0', description='Wit SDK for Python', author='The Wit Team', author_email='help@wit.ai', diff --git a/wit/wit.py b/wit/wit.py index 1d2ccc1..86aeecf 100644 --- a/wit/wit.py +++ b/wit/wit.py @@ -8,14 +8,17 @@ WIT_API_VERSION = os.getenv('WIT_API_VERSION', '20160516') DEFAULT_MAX_STEPS = 5 INTERACTIVE_PROMPT = '> ' +LEARN_MORE = 'Learn more at https://wit.ai/docs/quickstart' class WitError(Exception): pass -def req(access_token, meth, path, params, **kwargs): +def req(logger, access_token, meth, path, params, **kwargs): + full_url = WIT_API_HOST + path + logger.debug('%s %s %s', meth, full_url, params) rsp = requests.request( meth, - WIT_API_HOST + path, + full_url, headers={ 'authorization': 'Bearer ' + access_token, 'accept': 'application/vnd.wit.' + WIT_API_VERSION + '+json' @@ -29,16 +32,17 @@ def req(access_token, meth, path, params, **kwargs): json = rsp.json() if 'error' in json: raise WitError('Wit responded with an error: ' + json['error']) + + logger.debug('%s %s %s', meth, full_url, json) return json def validate_actions(logger, actions): - learn_more = 'Learn more at https://wit.ai/docs/quickstart' if not isinstance(actions, dict): logger.warn('The second parameter should be a dictionary.') - for action in ['say', 'merge', 'error']: + for action in ['send']: if action not in actions: logger.warn('The \'' + action + '\' action is missing. ' + - learn_more) + LEARN_MORE) for action in actions.keys(): if not hasattr(actions[action], '__call__'): logger.warn('The \'' + action + @@ -49,84 +53,85 @@ class Wit: access_token = None actions = {} - def __init__(self, access_token, actions, logger=None): + def __init__(self, access_token, actions=None, logger=None): self.access_token = access_token self.logger = logger or logging.getLogger(__name__) - self.actions = validate_actions(self.logger, actions) + if actions: + self.actions = validate_actions(self.logger, actions) def message(self, msg, verbose=None): - self.logger.debug('Message request: msg=%r verbose=%s', msg, verbose) params = {} - if msg: - params['q'] = msg if verbose: params['verbose'] = True - resp = req(self.access_token, 'GET', '/message', params) - self.logger.debug('Message response: %s', resp) + if msg: + params['q'] = msg + resp = req(self.logger, self.access_token, 'GET', '/message', params) return resp - def converse(self, session_id, msg, context=None, verbose=None): - self.logger.debug('Converse request: session_id=%s msg=%r context=%s verbose=%s', - session_id, msg, context, verbose) + def converse(self, session_id, message, context=None, verbose=None): if context is None: context = {} params = {'session_id': session_id} - if msg: - params['q'] = msg if verbose: params['verbose'] = True - resp = req(self.access_token, 'POST', '/converse', params, json=context) - self.logger.debug('Message response: %s', resp) + if message: + params['q'] = message + resp = req(self.logger, self.access_token, 'POST', '/converse', params, json=context) return resp - def __run_actions(self, session_id, msg, context, max_steps, verbose, - user_message): - if max_steps <= 0: - raise WitError('max iterations reached') - rst = self.converse(session_id, msg, context, verbose) - if 'type' not in rst: - raise WitError('couldn\'t find type in Wit response') - if rst['type'] == 'stop': + def __run_actions(self, session_id, message, context, i, verbose): + if i <= 0: + raise WitError('Max steps reached, stopping.') + json = self.converse(session_id, message, context, verbose) + if 'type' not in json: + raise WitError('Couldn\'t find type in Wit response') + + self.logger.debug('Context: %s', context) + self.logger.debug('Response type: %s', json['type']) + + # backwards-cpmpatibility with API version 20160516 + if json['type'] == 'merge': + json['type'] = 'action' + json['action'] = 'merge' + + if json['type'] == 'error': + raise WitError('Oops, I don\'t know what to do.') + + if json['type'] == 'stop': return context - if rst['type'] == 'msg': - if 'say' not in self.actions: - raise WitError('unknown action: say') - self.logger.info('Executing say with: {}'.format(rst['msg'].encode('utf8'))) - self.actions['say'](session_id, dict(context), rst['msg']) - elif rst['type'] == 'merge': - if 'merge' not in self.actions: - raise WitError('unknown action: merge') - self.logger.info('Executing merge') - context = self.actions['merge'](session_id, dict(context), - rst['entities'], user_message) - if context is None: - self.logger.warn('missing context - did you forget to return it?') - context = {} - elif rst['type'] == 'action': - if rst['action'] not in self.actions: - raise WitError('unknown action: ' + rst['action']) - self.logger.info('Executing action {}'.format(rst['action'])) - context = self.actions[rst['action']](session_id, dict(context)) + + request = { + 'session_id': session_id, + 'context': dict(context), + 'text': message, + 'entities': json.get('entities'), + } + if json['type'] == 'msg': + self.throw_if_action_missing('send') + response = { + 'text': json.get('msg').encode('utf8'), + 'quickreplies': json.get('quickreplies'), + } + self.actions['send'](request, response) + elif json['type'] == 'action': + action = json['action'] + self.throw_if_action_missing(action) + context = self.actions[action](request) if context is None: self.logger.warn('missing context - did you forget to return it?') context = {} - elif rst['type'] == 'error': - if 'error' not in self.actions: - raise WitError('unknown action: error') - self.logger.info('Executing error') - self.actions['error'](session_id, dict(context), - WitError('Oops, I don\'t know what to do.')) else: - raise WitError('unknown type: ' + rst['type']) - return self.__run_actions(session_id, None, context, max_steps - 1, - verbose, user_message) + raise WitError('unknown type: ' + json['type']) + return self.__run_actions(session_id, None, context, i - 1, verbose) - def run_actions(self, session_id, msg, context=None, + def run_actions(self, session_id, message, context=None, max_steps=DEFAULT_MAX_STEPS, verbose=None): + if not self.actions: + self.throw_must_have_actions() + if context is None: context = {} - return self.__run_actions(session_id, msg, context, max_steps, verbose, - msg) + return self.__run_actions(session_id, message, context, max_steps, verbose) def interactive(self, context=None, max_steps=DEFAULT_MAX_STEPS): """Runs interactive command line chat between user and bot. Runs @@ -135,21 +140,30 @@ def interactive(self, context=None, max_steps=DEFAULT_MAX_STEPS): context -- optional initial context. Set to {} if omitted max_steps -- max number of steps for run_actions. """ + if not self.actions: + self.throw_must_have_actions() if max_steps <= 0: raise WitError('max iterations reached') - # initialize/validate initial context - context = {} if context is None else context - # generate type 1 uuid for the session id - session_id = uuid.uuid1() - # input/raw_input are not interchangible between python 2 and 3. + if context is None: + context = {} + + # input/raw_input are not interchangible between python 2 and 3 try: input_function = raw_input except NameError: input_function = input - # main interactive loop. prompt user, pass msg to run_actions, repeat + + session_id = uuid.uuid1() while True: try: message = input_function(INTERACTIVE_PROMPT).rstrip() except (KeyboardInterrupt, EOFError): return context = self.run_actions(session_id, message, context, max_steps) + + def throw_if_action_missing(self, action_name): + if action_name not in self.actions: + raise WitError('unknown action: ' + action_name) + + def throw_must_have_actions(self): + raise WitError('You must provide the `actions` parameter to be able to use runActions. ' + LEARN_MORE)