From 6b2ad9b99e593d491f18bceebdf45463b35bd2be Mon Sep 17 00:00:00 2001 From: Jim Bennett Date: Sat, 26 Jun 2021 21:04:06 -0700 Subject: [PATCH] Migrating LUIS to an HTTP trigger --- .../2-language-understanding/README.md | 93 +++++++++++++++---- .../2-language-understanding/assignment.md | 2 +- .../smart-timer-trigger/local.settings.json | 3 +- .../speech-trigger/__init__.py | 43 --------- .../speech-trigger/function.json | 15 --- .../text-to-timer/__init__.py | 44 +++++++++ .../text-to-timer/function.json | 20 ++++ 7 files changed, 139 insertions(+), 81 deletions(-) delete mode 100644 6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/__init__.py delete mode 100644 6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/function.json create mode 100644 6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/__init__.py create mode 100644 6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/function.json diff --git a/6-consumer/lessons/2-language-understanding/README.md b/6-consumer/lessons/2-language-understanding/README.md index 29f99ae5..24b3a7b8 100644 --- a/6-consumer/lessons/2-language-understanding/README.md +++ b/6-consumer/lessons/2-language-understanding/README.md @@ -256,22 +256,38 @@ To use this model from code, you need to publish it. When publishing from LUIS, ## Use the language understanding model -Once published, the LUIS model can be called from code. In the last lesson you sent the recognized speech to an IoT Hub, and you can use serverless code to respond to this and understand what was sent. +Once published, the LUIS model can be called from code. In previous lessons, you have used an IoT Hub to handle communication with cloud services, sending telemetry and listening for commands. This is very asynchronous - once telemetry is sent your code doesn't wait for a response, and if the cloud service is down, you wouldn't know. + +For a smart timer, we want a response straight away, so we can tell the user that a timer is set, or alert them that the cloud services are unavailable. To do this, our IoT device will call a web endpoint directly, instead of relying on an IoT Hub. + +Rather than calling LUIS from the IoT device, you can use serverless code with a different type of trigger - an HTTP trigger. This allows your function app to listen for REST requests, and respond to them. + +> 💁 Although you can call LUIS directly from your IoT device, it's better to use something like serverless code. This way when of you want to change the LUIS app that you call, for example when you train a better model or train a model in a different language, you only have to update your cloud code, not re-deploy code to potentially thousands or millions of IoT device. ### Task - create a serverless functions app -1. Create an Azure Functions app called `smart-timer-trigger`. +1. Create an Azure Functions app called `smart-timer-trigger`, and open this in VS Code -1. Add an IoT Hub event trigger to this app called `speech-trigger`. +1. Add an HTTP trigger to this app called `speech-trigger` using the following command from inside the VS Code terminal: -1. Set the Event Hub compatible endpoint connection string for your IoT Hub in the `local.settings.json` file, and use the key for that entry in the `function.json` file. + ```sh + func new --name text-to-timer --template "HTTP trigger" + ``` -1. Use the Azurite app as a local storage emulator. + This will crate an HTTP trigger called `text-to-timer`. -1. Run your functions app and your IoT device to ensure speech is arriving at the IoT Hub. +1. Test the HTTP trigger by running the functions app. When it runs you will see the endpoint listed in the output: ```output - Python EventHub trigger processed an event: {"speech": "Set a 3 minute timer."} + Functions: + + text-to-timer: [GET,POST] http://localhost:7071/api/text-to-timer + ``` + + Test this by loading the [http://localhost:7071/api/text-to-timer](http://localhost:7071/api/text-to-timer) URL in your browser. + + ```output + This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response. ``` ### Task - use the language understanding model @@ -288,6 +304,12 @@ Once published, the LUIS model can be called from code. In the last lesson you s pip install -r requirements.txt ``` + > 💁 If you get errors, you may need to upgrade pip with the following command: + > + > ```sh + > pip install --upgrade pip + > ``` + 1. Add new entries to the `local.settings.json` file for your LUIS API Key, Endpoint URL, and App ID from the **MANAGE** tab of the LUIS portal: ```JSON @@ -313,7 +335,7 @@ Once published, the LUIS model can be called from code. In the last lesson you s This imports some system libraries, as well as the libraries to interact with LUIS. -1. In the `main` method, before it loops through all the events, add the following code: +1. Delete the contents of the `main` method, and add the following code: ```python luis_key = os.environ['LUIS_KEY'] @@ -326,14 +348,18 @@ Once published, the LUIS model can be called from code. In the last lesson you s This loads the values you added to the `local.settings.json` file for your LUIS app, creates a credentials object with your API key, then creates a LUIS client object to interact with your LUIS app. -1. Predictions are requested from LUIS by sending a prediction request - a JSON document containing the text to predict. Create this with the following code inside the `for event in events` loop: +1. This HTTP trigger will be called passing the text to understand as an HTTP parameter. These are key/value pairs sent as part of the URL. For this app, the key will be `text` and the value will be the text to understand. The following code extracts the value from the HTTP request, and logs it to the console. Add this code to the `main` function: ```python - event_body = json.loads(event.get_body().decode('utf-8')) - prediction_request = { 'query' : event_body['speech'] } + text = req.params.get('text') + logging.info(f'Request - {text}') ``` - This code extracts the speech that was sent to the IoT Hub and uses it to build the prediction request. +1. Predictions are requested from LUIS by sending a prediction request - a JSON document containing the text to predict. Create this with the following code: + + ```python + prediction_request = { 'query' : text } + ``` 1. This request can then be sent to LUIS, using the staging slot that your app was published to: @@ -373,7 +399,7 @@ Once published, the LUIS model can be called from code. In the last lesson you s * *"Set a 30 second timer"* - this will have one number, `30`, and one time unit, `second` so the single number will match the single time unit. * *"Set a 2 minute and 30 second timer"* - this will have two numbers, `2` and `30`, and two time units, `minute` and `second` so the first number will be for the first time unit (2 minutes), and the second number for the second time unit (30 seconds). - The following code gets the count of items in the number entities, and uses that to extract the first item from each array, then the second and so on: + The following code gets the count of items in the number entities, and uses that to extract the first item from each array, then the second and so on. Add this inside the `if` block. ```python for i in range(0, len(numbers)): @@ -397,20 +423,46 @@ Once published, the LUIS model can be called from code. In the last lesson you s total_seconds += number ``` -1. Finally, outside this loop through the entities, log the total time for the timer: +1. Outside this loop through the entities, log the total time for the timer: ```python logging.info(f'Timer required for {total_seconds} seconds') ``` -1. Run the function app and speak into your IoT device. You will see the total time for the timer in the function app output: +1. The number of seconds needs to be returned from the function as an HTTP response. At the end of the `if` block, add the following: + + ```python + payload = { + 'seconds': total_seconds + } + return func.HttpResponse(json.dumps(payload), status_code=200) + ``` + + This code creates a payload containing the total number of seconds for the timer, converts it to a JSON string and returns it as an HTTP result with a status code of 200, which means the call was successful. + +1. Finally, outside the `if` block, handle if the intent was not recognized by returning an error code: + + ```python + return func.HttpResponse(status_code=404) + ``` + + 404 is the status code for *not found*. + +1. Run the function app and test it out by passing text to the URL. URLs cannot contain spaces, so you will need to encode spaces in a way that URLs can use. The encoding for a space is `%20`, so replace all the spaces in the text with `%20`. For example, to test "Set a 2 minutes 27 second timer", use the following URL: + + [http://localhost:7071/api/text-to-timer?text=Set%20a%202%20minutes%2027%20second%20timer](http://localhost:7071/api/text-to-timer?text=Set%20a%202%20minutes%2027%20second%20timer) ```output - [2021-06-16T01:38:33.316Z] Executing 'Functions.speech-trigger' (Reason='(null)', Id=39720c37-b9f1-47a9-b213-3650b4d0b034) - [2021-06-16T01:38:33.329Z] Trigger Details: PartionId: 0, Offset: 3144-3144, EnqueueTimeUtc: 2021-06-16T01:38:32.7970000Z-2021-06-16T01:38:32.7970000Z, SequenceNumber: 8-8, Count: 1 - [2021-06-16T01:38:33.605Z] Python EventHub trigger processed an event: {"speech": "Set a four minute 17 second timer."} - [2021-06-16T01:38:35.076Z] Timer required for 257 seconds - [2021-06-16T01:38:35.128Z] Executed 'Functions.speech-trigger' (Succeeded, Id=39720c37-b9f1-47a9-b213-3650b4d0b034, Duration=1894ms) + Functions: + + text-to-timer: [GET,POST] http://localhost:7071/api/text-to-timer + + For detailed output, run func with --verbose flag. + [2021-06-26T19:45:14.502Z] Worker process started and initialized. + [2021-06-26T19:45:19.338Z] Host lock lease acquired by instance ID '000000000000000000000000951CAE4E'. + [2021-06-26T19:45:52.059Z] Executing 'Functions.text-to-timer' (Reason='This function was programmatically called via the host APIs.', Id=f68bfb90-30e4-47a5-99da-126b66218e81) + [2021-06-26T19:45:53.577Z] Timer required for 147 seconds + [2021-06-26T19:45:53.746Z] Executed 'Functions.text-to-timer' (Succeeded, Id=f68bfb90-30e4-47a5-99da-126b66218e81, Duration=1750ms) ``` > 💁 You can find this code in the [code/functions](code/functions) folder. @@ -429,6 +481,7 @@ There are many ways to request the same thing, such as setting a timer. Think of * Read more about LUIS and it's capabilities on the [Language Understanding (LUIS) documentation page on Microsoft docs](https://docs.microsoft.com/azure/cognitive-services/luis/?WT.mc_id=academic-17441-jabenn) * Read more about language understanding on the [Natural-language understanding page on Wikipedia](https://wikipedia.org/wiki/Natural-language_understanding) +* Read more on HTTP triggers in the [Azure Functions HTTP trigger documentation on Microsoft docs](https://docs.microsoft.com/azure/azure-functions/functions-bindings-http-webhook-trigger?tabs=python&WT.mc_id=academic-17441-jabenn) ## Assignment diff --git a/6-consumer/lessons/2-language-understanding/assignment.md b/6-consumer/lessons/2-language-understanding/assignment.md index f3c5e301..acfdd3ae 100644 --- a/6-consumer/lessons/2-language-understanding/assignment.md +++ b/6-consumer/lessons/2-language-understanding/assignment.md @@ -4,7 +4,7 @@ So far in this lesson you have trained a model to understand setting a timer. Another useful feature is cancelling a timer - maybe your bread is ready and can be taken out of the oven before the timer is elapsed. -Add a new intent to your LUIS app to cancel the timer. It won't need any entities, but will need some example sentences. Handle this in your serverless code if it is the top intent, logging that the intent was recognized. +Add a new intent to your LUIS app to cancel the timer. It won't need any entities, but will need some example sentences. Handle this in your serverless code if it is the top intent, logging that the intent was recognized and returning an appropriate response. ## Rubric diff --git a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/local.settings.json b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/local.settings.json index abde93a8..ee6b34ce 100644 --- a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/local.settings.json +++ b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/local.settings.json @@ -2,8 +2,7 @@ "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "IOT_HUB_CONNECTION_STRING": "", + "AzureWebJobsStorage": "", "LUIS_KEY": "", "LUIS_ENDPOINT_URL": "", "LUIS_APP_ID": "" diff --git a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/__init__.py b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/__init__.py deleted file mode 100644 index 1b9f3ac5..00000000 --- a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import List -import logging - -import azure.functions as func - -import json -import os -from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient -from msrest.authentication import CognitiveServicesCredentials - -def main(events: List[func.EventHubEvent]): - luis_key = os.environ['LUIS_KEY'] - endpoint_url = os.environ['LUIS_ENDPOINT_URL'] - app_id = os.environ['LUIS_APP_ID'] - - credentials = CognitiveServicesCredentials(luis_key) - client = LUISRuntimeClient(endpoint=endpoint_url, credentials=credentials) - - for event in events: - logging.info('Python EventHub trigger processed an event: %s', - event.get_body().decode('utf-8')) - - event_body = json.loads(event.get_body().decode('utf-8')) - prediction_request = { 'query' : event_body['speech'] } - - prediction_response = client.prediction.get_slot_prediction(app_id, 'Staging', prediction_request) - - if prediction_response.prediction.top_intent == 'set timer': - numbers = prediction_response.prediction.entities['number'] - time_units = prediction_response.prediction.entities['time unit'] - total_seconds = 0 - - for i in range(0, len(numbers)): - number = numbers[i] - time_unit = time_units[i][0] - - if time_unit == 'minute': - total_seconds += number * 60 - else: - total_seconds += number - - logging.info(f'Timer required for {total_seconds} seconds') - diff --git a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/function.json b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/function.json deleted file mode 100644 index 0117bdf5..00000000 --- a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/speech-trigger/function.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "scriptFile": "__init__.py", - "bindings": [ - { - "type": "eventHubTrigger", - "name": "events", - "direction": "in", - "eventHubName": "samples-workitems", - "connection": "IOT_HUB_CONNECTION_STRING", - "cardinality": "many", - "consumerGroup": "$Default", - "dataType": "binary" - } - ] -} \ No newline at end of file diff --git a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/__init__.py b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/__init__.py new file mode 100644 index 00000000..84d0df46 --- /dev/null +++ b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/__init__.py @@ -0,0 +1,44 @@ +import logging + +import azure.functions as func +import json +import os +from azure.cognitiveservices.language.luis.runtime import LUISRuntimeClient +from msrest.authentication import CognitiveServicesCredentials + + +def main(req: func.HttpRequest) -> func.HttpResponse: + luis_key = os.environ['LUIS_KEY'] + endpoint_url = os.environ['LUIS_ENDPOINT_URL'] + app_id = os.environ['LUIS_APP_ID'] + + credentials = CognitiveServicesCredentials(luis_key) + client = LUISRuntimeClient(endpoint=endpoint_url, credentials=credentials) + + text = req.params.get('text') + prediction_request = { 'query' : text } + + prediction_response = client.prediction.get_slot_prediction(app_id, 'Staging', prediction_request) + + if prediction_response.prediction.top_intent == 'set timer': + numbers = prediction_response.prediction.entities['number'] + time_units = prediction_response.prediction.entities['time unit'] + total_seconds = 0 + + for i in range(0, len(numbers)): + number = numbers[i] + time_unit = time_units[i][0] + + if time_unit == 'minute': + total_seconds += number * 60 + else: + total_seconds += number + + logging.info(f'Timer required for {total_seconds} seconds') + + payload = { + 'seconds': total_seconds + } + return func.HttpResponse(json.dumps(payload), status_code=200) + + return func.HttpResponse(status_code=404) \ No newline at end of file diff --git a/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/function.json b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/function.json new file mode 100644 index 00000000..d9019652 --- /dev/null +++ b/6-consumer/lessons/2-language-understanding/code/functions/smart-timer-trigger/text-to-timer/function.json @@ -0,0 +1,20 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} \ No newline at end of file