diff --git a/docs/matchers/recast.ai.md b/docs/matchers/recast.ai.md new file mode 100644 index 000000000..e6f60b527 --- /dev/null +++ b/docs/matchers/recast.ai.md @@ -0,0 +1,115 @@ +# wit.ai Matcher + +[Recast.AI](https://recast.ai/) is an NLP API for matching strings to [intents](https://recast.ai/docs/intent). Intents are created on the Recast.AI website. + +## [Example 1](#example1) + +```python +from opsdroid.matchers import match_recastai + +@match_recastai('greetings') +async def hello(opsdroid, config, message): + """Replies to user when any 'greetings' + intent is returned by Recast.AI + """ + await message.respond("Hello there!") +``` + +The above skill would be called on any intent which has a name of `'greetings'`. + +## Example 2 + +```python +from opsdroid.matchers import match_recastai + +@match_recastai('ask-joke') +async def my_skill(opsdroid, config, message): + """Returns a joke if asked by the user""" + await message.respond('What do you call a bear with no teeth? -- A gummy bear!') +``` + +The above skill would be called on any intent which has a name of `'ask-joke'`. + + +## Creating a Recast.AI bot +You need to [register](https://recast.ai/signup) on Recast.AI and create a bot in order to use Recast.AI with opsdroid. + +You can find a quick getting started with the Recast.AI guide [here](https://recast.ai/docs/create-your-bot). + +## Configuring opsdroid + +In order to enable Recast.AI skills, you must specify an `access-token` for your bot in the parsers section of the opsdroid configuration file. +You can find this `access-token` in the settings of your bot under the name: `'Request access token'`. + +You can also set a `min-score` option to tell opsdroid to ignore any matches which score less than a given number between 0 and 1. The default for this is 0 which will match all messages. + +```yaml + +parsers: + - name: recastai + access-token: 85769fjoso084jd + min-score: 0.8 +``` + +## Message object additional parameters + +### `message.recastai` + +An http response object which has been returned by the Recast.AI API. This allows you to access any information from the matched intent including other entities, intents, values, etc. + + +## Example Skill + +```python +from opsdroid.matchers import match_recastai + +import json + + +@match_recastai('ask-feeling') +async def dumpResponse(opsdroid, config, message): + print(json.dumps(message.witai)) +``` + +### Return Value on "How are you?" + +The example skill will print the following on the message "how are you?". + +```json +{ + "results": + { + "uuid": "cab86e23-caaf-4131-9b83-a564887203da", + "source": "how are you?", + "intents": [ + { + "slug": "ask-feeling", + "confidence": 0.99 + } + ], + "act": "wh-query", + "type": "desc:manner", + "sentiment": "neutral", + "entities": + { + "pronoun": [ + { + "person": 2, + "number": "singular", + "gender": "unknown", + "raw": "you", + "confidence": 0.99 + } + ] + }, + "language": "en", + "processing_language": "en", + "version": "2.10.1", + "timestamp": "2017-11-15T11:50:51.478057+00:00", + "status": 200}, + "message": "Requests rendered with success" + } +} +``` + + diff --git a/mkdocs.yml b/mkdocs.yml index 6c51b15dc..5a8ffdd07 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ pages: - Regular Expressions: 'matchers/regex.md' - Dialogflow (Api.ai): 'matchers/dialogflow.md' - LUIS.AI: 'matchers/luis.ai.md' + - Recast.AI: 'matchers/recast.ai.md' - wit.ai: 'matchers/wit.ai.md' - Crontab: 'matchers/crontab.md' - Webhook: 'matchers/webhook.md' diff --git a/opsdroid/core.py b/opsdroid/core.py index 062adc00f..69ed69030 100644 --- a/opsdroid/core.py +++ b/opsdroid/core.py @@ -15,6 +15,7 @@ from opsdroid.parsers.regex import parse_regex from opsdroid.parsers.dialogflow import parse_dialogflow from opsdroid.parsers.luisai import parse_luisai +from opsdroid.parsers.recastai import parse_recastai from opsdroid.parsers.witai import parse_witai from opsdroid.parsers.crontab import parse_crontab from opsdroid.const import DEFAULT_CONFIG_PATH @@ -242,6 +243,14 @@ async def get_ranked_skills(self, message): skills = skills + \ await parse_luisai(self, message, luisai[0]) + recastai = [p for p in parsers if p["name"] == "recastai"] + if len(recastai) == 1 and \ + ("enabled" not in recastai[0] or + recastai[0]["enabled"] is not False): + _LOGGER.debug("Checking Recast.AI...") + skills = skills + \ + await parse_recastai(self, message, recastai[0]) + witai = [p for p in parsers if p["name"] == "witai"] if len(witai) == 1 and \ ("enabled" not in witai[0] or diff --git a/opsdroid/matchers.py b/opsdroid/matchers.py index 3752494af..efdc662aa 100644 --- a/opsdroid/matchers.py +++ b/opsdroid/matchers.py @@ -89,6 +89,18 @@ def matcher(func): return matcher +def match_recastai(intent): + """Return recastai intent match decorator.""" + def matcher(func): + """Add decorated function to skills list for recastai matching.""" + opsdroid = get_opsdroid() + opsdroid.skills.append({"recastai_intent": intent, "skill": func, + "config": + opsdroid.loader.current_import_config}) + return func + return matcher + + def match_witai(intent): """Return witai intent match decorator.""" def matcher(func): diff --git a/opsdroid/parsers/recastai.py b/opsdroid/parsers/recastai.py new file mode 100644 index 000000000..17cff177c --- /dev/null +++ b/opsdroid/parsers/recastai.py @@ -0,0 +1,75 @@ +"""A helper function for parsing and executing Recast.AI skills.""" +import logging +import json + +import aiohttp + + +_LOGGER = logging.getLogger(__name__) + + +async def call_recastai(message, config): + """Call the recastai api and return the response.""" + async with aiohttp.ClientSession() as session: + payload = { + "language": "en", + "text": message.text + } + headers = { + "Authorization": "Token " + config['access-token'], + "Content-Type": "application/json" + } + resp = await session.post("https://api.recast.ai/v2/request", + data=json.dumps(payload), + headers=headers) + result = await resp.json() + _LOGGER.info("Recastai response - %s", json.dumps(result)) + + return result + + +async def parse_recastai(opsdroid, message, config): + """Parse a message against all recastai intents.""" + matched_skills = [] + if 'access-token' in config: + try: + result = await call_recastai(message, config) + except aiohttp.ClientOSError: + _LOGGER.error("No response from Recast.AI, check your network.") + return matched_skills + + if result['results'] is None: + _LOGGER.error("Recast.AI error - %s", result["message"]) + return matched_skills + elif not result["results"]["intents"]: + _LOGGER.error("Recast.AI error - No intent found " + "for the message %s", str(message.text)) + return matched_skills + + # try: + # confidence = result["results"]["intents"][0]["confidence"] + # except (KeyError, IndexError): + # confidence = 0.0 + if "min-score" in config and \ + result["results"]["intents"][0]["confidence"] < \ + config["min-score"]: + _LOGGER.debug("Recast.AI score lower than min-score") + return matched_skills + + if result: + for skill in opsdroid.skills: + if "recastai_intent" in skill: + if (skill["recastai_intent"] in + result["results"]["intents"][0]["slug"]): + message.recastai = result + _LOGGER.debug("Matched against skill %s", + skill["config"]["name"]) + + matched_skills.append({ + "score": + result["results"]["intents"][0]["confidence"], + "skill": skill["skill"], + "config": skill["config"], + "message": message + }) + return matched_skills diff --git a/tests/test_core.py b/tests/test_core.py index 4b99aa2a9..2fdb60aa2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -10,7 +10,8 @@ from opsdroid.message import Message from opsdroid.connector import Connector from opsdroid.matchers import (match_regex, match_dialogflow_action, - match_luisai_intent, match_witai) + match_luisai_intent, match_recastai, + match_witai) class TestCore(unittest.TestCase): @@ -228,6 +229,20 @@ async def test_parse_luisai(self): for task in tasks: await task + async def test_parse_recastai(self): + with OpsDroid() as opsdroid: + opsdroid.config["parsers"] = [{"name": "recastai"}] + recastai_intent = "" + skill = amock.CoroutineMock() + mock_connector = Connector({}) + match_recastai(recastai_intent)(skill) + message = Message("Hello", "user", "default", mock_connector) + with amock.patch('opsdroid.parsers.recastai.parse_recastai'): + tasks = await opsdroid.parse(message) + self.assertEqual(len(tasks), 1) + for task in tasks: + await task + async def test_parse_witai(self): with OpsDroid() as opsdroid: opsdroid.config["parsers"] = [{"name": "witai"}] diff --git a/tests/test_parser_recastai.py b/tests/test_parser_recastai.py new file mode 100644 index 000000000..43cb79dfc --- /dev/null +++ b/tests/test_parser_recastai.py @@ -0,0 +1,268 @@ + +import asynctest +import asynctest.mock as amock + +from aiohttp import helpers, ClientOSError + +from opsdroid.core import OpsDroid +from opsdroid.matchers import match_recastai +from opsdroid.message import Message +from opsdroid.parsers import recastai +from opsdroid.connector import Connector + + +class TestParserRecastAi(asynctest.TestCase): + """Test the opsdroid recastai parser.""" + + async def test_call_recastai(self): + mock_connector = Connector({}) + message = Message("Hello", "user", "default", mock_connector) + config = {'name': 'recastai', 'access-token': 'test'} + result = amock.Mock() + result.json = amock.CoroutineMock() + result.json.return_value = { + 'results': + { + "uuid": "f482bddd-a9d7-41ae-aae3-6e64ad3f02dc", + "source": "hello", + "intents": [ + { + "slug": "greetings", + "confidence": 0.99 + } + ], + "act": "assert", + "type": None, + "sentiment": "vpositive", + "entities": {}, + "language": "en", + "processing_language": "en", + "version": "2.10.1", + "timestamp": "2017-11-15T07:41:48.935990+00:00", + "status": 200 + } + } + + with amock.patch('aiohttp.ClientSession.post') as patched_request: + patched_request.return_value = helpers.create_future(self.loop) + patched_request.return_value.set_result(result) + await recastai.call_recastai(message, config) + self.assertTrue(patched_request.called) + + async def test_parse_recastai(self): + with OpsDroid() as opsdroid: + opsdroid.config['parsers'] = [ + {'name': 'recastai', 'access-token': "test"} + ] + mock_skill = amock.CoroutineMock() + opsdroid.loader.current_import_config = { + "name": "greetings" + } + match_recastai('greetings')(mock_skill) + + mock_connector = amock.CoroutineMock() + message = Message("Hello", "user", "default", mock_connector) + + with amock.patch.object(recastai, 'call_recastai') as \ + mocked_call_recastai: + mocked_call_recastai.return_value = { + 'results': + { + "uuid": "f482bddd-a9d7-41ae-aae3-6e64ad3f02dc", + "source": "hello", + "intents": [ + { + "slug": "greetings", + "confidence": 0.99 + } + ], + "act": "assert", + "type": None, + "sentiment": "vpositive", + "entities": {}, + "language": "en", + "processing_language": "en", + "version": "2.10.1", + "timestamp": "2017-11-15T07:41:48.935990+00:00", + "status": 200 + } + } + skills = await recastai.parse_recastai( + opsdroid, message, opsdroid.config['parsers'][0]) + self.assertEqual(mock_skill, skills[0]["skill"]) + + async def test_parse_recastai_raises(self): + with OpsDroid() as opsdroid: + opsdroid.config['parsers'] = [ + {'name': 'recastai', 'access-token': "test"} + ] + mock_skill = amock.CoroutineMock() + mock_skill.side_effect = Exception() + opsdroid.loader.current_import_config = { + "name": "mocked-intent" + } + match_recastai('greetings')(mock_skill) + + mock_connector = amock.MagicMock() + mock_connector.respond = amock.CoroutineMock() + message = Message("Hello", "user", "default", mock_connector) + + with amock.patch.object(recastai, 'call_recastai') as \ + mocked_call_recastai: + mocked_call_recastai.return_value = { + 'results': + { + "uuid": "f482bddd-a9d7-41ae-aae3-6e64ad3f02dc", + "source": "hello", + "intents": [ + { + "slug": "greetings", + "confidence": 0.99 + } + ], + "act": "assert", + "type": None, + "sentiment": "vpositive", + "entities": {}, + "language": "en", + "processing_language": "en", + "version": "2.10.1", + "timestamp": "2017-11-15T07:41:48.935990+00:00", + "status": 200 + } + } + + skills = await recastai.parse_recastai( + opsdroid, message, opsdroid.config['parsers'][0]) + self.assertEqual(mock_skill, skills[0]["skill"]) + + await opsdroid.run_skill( + skills[0]["skill"], skills[0]["config"], message) + self.assertTrue(skills[0]["skill"].called) + + async def test_parse_recastai_failure(self): + with OpsDroid() as opsdroid: + opsdroid.config['parsers'] = [ + {'name': 'recastai', 'access-token': "test"} + ] + mock_skill = amock.CoroutineMock() + match_recastai('greetings')(mock_skill) + + mock_connector = amock.CoroutineMock() + message = Message("", "user", "default", mock_connector) + + with amock.patch.object(recastai, 'call_recastai') as \ + mocked_call_recastai: + mocked_call_recastai.return_value = { + 'results': None, + 'message': 'Text is empty' + } + skills = await recastai.parse_recastai( + opsdroid, message, opsdroid.config['parsers'][0]) + self.assertFalse(skills) + + async def test_parse_recastai_no_intent(self): + with OpsDroid() as opsdroid: + opsdroid.config['parsers'] = [ + {'name': 'recastai', 'access-token': "test"} + ] + mock_skill = amock.CoroutineMock() + match_recastai('greetings')(mock_skill) + + mock_connector = amock.CoroutineMock() + message = Message( + "kdjiruetosakdg", + "user", + "default", + mock_connector) + + with amock.patch.object(recastai, 'call_recastai') as \ + mocked_call_recastai: + mocked_call_recastai.return_value = { + 'results': + { + 'uuid': 'e4b365be-815b-4e40-99c3-7a25583b4892', + 'source': 'kdjiruetosakdg', + 'intents': [], + 'act': 'assert', + 'type': None, + 'sentiment': 'neutral', + 'entities': {}, + 'language': 'en', + 'processing_language': 'en', + 'version': '2.10.1', + 'timestamp': '2017-11-15T07:32:42.641604+00:00', + 'status': 200}} + with amock.patch( + 'opsdroid.parsers.recastai._LOGGER.error') as logmock: + skills = await recastai.parse_recastai( + opsdroid, message, opsdroid.config['parsers'][0]) + self.assertTrue(logmock.called) + self.assertFalse(skills) + + async def test_parse_recastai_low_score(self): + with OpsDroid() as opsdroid: + opsdroid.config['parsers'] = [ + { + 'name': 'recastai', + 'access-token': "test", + "min-score": 1.0 + } + ] + mock_skill = amock.CoroutineMock() + match_recastai('intent')(mock_skill) + + mock_connector = amock.CoroutineMock() + message = Message("Hello", "user", "default", mock_connector) + + with amock.patch.object(recastai, 'call_recastai') as \ + mocked_call_recastai: + mocked_call_recastai.return_value = { + 'results': + { + "uuid": "f482bddd-a9d7-41ae-aae3-6e64ad3f02dc", + "source": "hello", + "intents": [ + { + "slug": "greetings", + "confidence": 0.99 + } + ], + "act": "assert", + "type": None, + "sentiment": "vpositive", + "entities": {}, + "language": "en", + "processing_language": "en", + "version": "2.10.1", + "timestamp": "2017-11-15T07:41:48.935990+00:00", + "status": 200 + } + } + await recastai.parse_recastai( + opsdroid, message, opsdroid.config['parsers'][0]) + + self.assertFalse(mock_skill.called) + + async def test_parse_recastai_raise_ClientOSError(self): + with OpsDroid() as opsdroid: + opsdroid.config['parsers'] = [ + { + 'name': 'recastai', + 'access-token': "test", + } + ] + mock_skill = amock.CoroutineMock() + match_recastai('greetings')(mock_skill) + + mock_connector = amock.CoroutineMock() + message = Message("Hello", "user", "default", mock_connector) + + with amock.patch.object(recastai, 'call_recastai') \ + as mocked_call: + mocked_call.side_effect = ClientOSError() + await recastai.parse_recastai( + opsdroid, message, opsdroid.config['parsers'][0]) + + self.assertFalse(mock_skill.called) + self.assertTrue(mocked_call.called)