From ccf854d005b359bd08c0141b1086f252af390720 Mon Sep 17 00:00:00 2001 From: Jaumb Date: Tue, 11 Jun 2019 21:15:23 -0400 Subject: [PATCH 1/2] Add polling station intent --- brooklinevoiceapp/brookline_controller.py | 3 + .../interaction_models/en_US.json | 125 ++++++++++++++++-- .../mycity/intents/polling_stations_intent.py | 108 +++++++++++++++ .../test_polling_stations_intent.py | 37 ++++++ .../mycity/test/test_constants.py | 43 +++++- .../mycity/test/unit_tests/test_gis_utils.py | 6 +- .../utils/brookline_arcgis_api_utils.py | 6 +- 7 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 brooklinevoiceapp/mycity/intents/polling_stations_intent.py create mode 100644 brooklinevoiceapp/mycity/test/integration_tests/test_polling_stations_intent.py diff --git a/brooklinevoiceapp/brookline_controller.py b/brooklinevoiceapp/brookline_controller.py index f14bbb5..97b5f8f 100644 --- a/brooklinevoiceapp/brookline_controller.py +++ b/brooklinevoiceapp/brookline_controller.py @@ -5,6 +5,7 @@ from mycity.mycity_response_data_model import MyCityResponseDataModel from mycity.intents.police_station_intent import find_closest_police_station from mycity.intents.trash_day_intent import get_trash_pickup_info +from mycity.intents.polling_stations_intent import get_polling_location_info import logging logger = logging.getLogger(__name__) @@ -106,6 +107,8 @@ def on_intent(mycity_request): return find_closest_police_station(mycity_request) elif mycity_request.intent_name == "TrashDayIntent": return get_trash_pickup_info(mycity_request) + elif mycity_request.intent_name == "PollingStationIntent": + return get_polling_location_info(mycity_request) else: raise ValueError("Invalid Intent") diff --git a/brooklinevoiceapp/interaction_models/en_US.json b/brooklinevoiceapp/interaction_models/en_US.json index 81ef5e4..cfc9371 100644 --- a/brooklinevoiceapp/interaction_models/en_US.json +++ b/brooklinevoiceapp/interaction_models/en_US.json @@ -75,9 +75,102 @@ "when is trash picked up at {Address}", "When is trash day at {Address}" ] + }, + { + "name": "PollingStationIntent", + "slots": [ + { + "name": "Address", + "type": "AMAZON.StreetAddress", + "samples": [ + "The address is {Address}", + "My address is {Address}", + "{Address}" + ] + }, + { + "name": "number_requests", + "type": "AMAZON.NUMBER" + } + ], + "samples": [ + "Nearest polling stations", + "Nearest polling stations to {Address}", + "Nearest {number_requests} polling stations", + "Nearest {number_requests} polling stations to {Address}", + "Ask for the {number_requests} closest polling stations", + "Ask for the closest polling stations", + "Ask for the {number_requests} nearest polling stations", + "Ask for the polling stations", + "Say the {number_requests} closest polling stations", + "Say the closest polling stations", + "Say the nearest {number_requests} polling stations", + "Say the nearest polling stations", + "Say the polling stations", + "Tell me the {number_requests} closest polling stations", + "Tell me the closest polling stations", + "Tell me the closest polling stations {Address}", + "Tell me the nearest polling stations", + "Tell me the nearest polling stations to {Address}", + "Tell me {number_requests} polling stations", + "Tell me the polling stations", + "Request {number_requests} closest polling stations", + "Request closest polling stations", + "Request {number_requests} nearest polling stations", + "Request nearest polling stations", + "Request the polling stations", + "report {number_requests} closest polling stations", + "report closest polling stations", + "report nearest {number_requests} polling stations", + "report nearest polling stations", + "report the polling stations", + "I need the {number_requests} closest polling stations", + "I need the closest polling stations", + "I need the {number_requests} nearest polling stations", + "I need the nearest polling stations", + "I need the polling stations", + "I want the closest polling stations", + "I want polling stations", + "I want nearest polling stations", + "I want the {number_requests} nearest polling stations", + "I want the nearest polling stations", + "I want the polling stations", + "Give the {number_requests} closest polling stations", + "Give the {number_requests} nearest polling stations", + "Give the closest polling stations", + "Give the polling stations", + "Give me the polling stations", + "Give me the polling stations closest to {Address}", + "Give me the {number_requests} closest polling stations", + "Give me the {number_requests} nearest polling stations", + "Give me the nearest {number_requests} polling stations", + "Give me the nearest polling stations", + "Get me the {number_requests} closest polling stations", + "Get me the closest polling stations", + "Get me the closest polling stations to {Address}", + "Get {number_requests} nearby polling stations", + "Get the nearest polling stations", + "Get me the nearest {number_requests} polling stations", + "Get me the closest {number_requests} polling stations", + "Get me the nearest polling stations", + "Get me the polling stations", + "What are polling stations", + "What's the {number_requests} nearby polling stations", + "What's the {number_requests} polling stations near me", + "What's the closest polling stations", + "What's the nearest {number_requests} polling stations", + "What's the nearest polling stations", + "What polling stations", + "What's the polling stations", + "Give me {number_requests} polling stations", + "Give me {number_requests} polling stations at {Address}", + "{number_requests} polling stations near me", + "{number_requests} polling stations", + "Read me {number_requests} polling stations requests", + "Polling stations request" + ] } - ], - "types": [] + ] }, "dialog": { "intents": [ @@ -109,7 +202,24 @@ "confirmationRequired": false, "elicitationRequired": true, "prompts": { - "elicitation": "Elicit.Slot.1200741587813.991075352244" + "elicitation": "Elicit.Slot.AskAddress" + } + } + ] + }, + { + "name": "PollingStationIntent", + "delegationStrategy": "SKILL_RESPONSE", + "confirmationRequired": false, + "prompts": {}, + "slots": [ + { + "name": "Address", + "type": "AMAZON.StreetAddress", + "confirmationRequired": false, + "elicitationRequired": true, + "prompts": { + "elicitation": "Elicit.Slot.AskAddress" } } ] @@ -126,15 +236,6 @@ "value": "What is your address?" } ] - }, - { - "id": "Elicit.Slot.1200741587813.991075352244", - "variations": [ - { - "type": "PlainText", - "value": "What is your address?" - } - ] } ] } diff --git a/brooklinevoiceapp/mycity/intents/polling_stations_intent.py b/brooklinevoiceapp/mycity/intents/polling_stations_intent.py new file mode 100644 index 0000000..b55a1f6 --- /dev/null +++ b/brooklinevoiceapp/mycity/intents/polling_stations_intent.py @@ -0,0 +1,108 @@ +""" Intent for responding to polling station requests """ +import logging +from mycity.mycity_response_data_model import MyCityResponseDataModel +from mycity.intents import intent_constants +from mycity.utils.address_utils import set_address_in_session +from mycity.utils.brookline_arcgis_api_utils import get_polling_locations_json + + +LOGGER = logging.getLogger(__name__) + +# User facing strings +CARD_TITLE_POLLING = "Polling Locations" +OUTPUT_SPEECH_TEMPLATE = "The {} polling station in {} is located at {}. " + +# Strings used in parsing json data returned by server +FEATURES_PATH = "features" +ATTRIBUTES_PATH = "attributes" +NAME_PATH = "NAME" +PRECINCT_PATH = "PRECINCT" +ADDR_PATH = "FULLADD" + +# Request data model strings +NUMBER_LOCATIONS_SLOT_NAME = "number_requests" +MAX_LOCATIONS = 10 +DEFAULT_LOCATIONS = 3 + +def get_polling_location_info(mycity_request): + """ + Generates a response to a polling location request + + :param mycity_request: MyCityRequestDataModel containing the user request + :return: MyCityResponseDataModel containing the speech to return to the user + """ + LOGGER.debug('Getting polling location information') + + response = MyCityResponseDataModel() + set_address_in_session(mycity_request) + current_address = \ + mycity_request.session_attributes.get(intent_constants.CURRENT_ADDRESS_KEY) + if current_address is None: + # Delegate to the Alexa interaction model for getting the user address + LOGGER.debug('Requesting user address.') + response.dialog_directive = "Delegate" + else: + response.output_speech = _get_output_speech_for_address(current_address, mycity_request) + response.card_title = CARD_TITLE_POLLING + + return response + + +def _get_output_speech_for_address(address, mycity_request): + """ + Creates output speech for polling locations near the provided address + + :param address: Current address + :return: Output speech string + """ + number_locations = _number_of_locations(mycity_request) + response = get_polling_locations_json(address) + try: + results = response[FEATURES_PATH] + output_speech = "" + for result in results[:number_locations]: + output_speech += _build_speech_from_result(result) + except (IndexError, KeyError): + LOGGER.error("Error extracting polling station response.") + return intent_constants.NO_RESULTS_RESPONSE + + if not output_speech: + return intent_constants.NO_RESULTS_RESPONSE + + return output_speech + + +def _number_of_locations(mycity_request): + """ + Returns number of locations from the request if available or a default value + :param mycity_request: MyCityRequestDataModel object + :return: Number of polling station requests to return from this intent + """ + if NUMBER_LOCATIONS_SLOT_NAME in \ + mycity_request.intent_variables and \ + "value" in mycity_request.intent_variables[ + NUMBER_LOCATIONS_SLOT_NAME]: + return min( + int(mycity_request.intent_variables[NUMBER_LOCATIONS_SLOT_NAME]["value"]), + MAX_LOCATIONS) + + return DEFAULT_LOCATIONS + + +def _build_speech_from_result(result): + """ + Builds a speech string from a given polling location result + :param result: JSON object of a single polling location result + :return: Speech string representing this result + """ + + try: + attributes = result[ATTRIBUTES_PATH] + name = attributes[NAME_PATH] + precinct = attributes[PRECINCT_PATH] + address = attributes[ADDR_PATH] + except KeyError: + LOGGER.error("Polling station response json did not contain the expected attributes.") + raise KeyError + + return OUTPUT_SPEECH_TEMPLATE.format(name, precinct, address) diff --git a/brooklinevoiceapp/mycity/test/integration_tests/test_polling_stations_intent.py b/brooklinevoiceapp/mycity/test/integration_tests/test_polling_stations_intent.py new file mode 100644 index 0000000..94d8c8e --- /dev/null +++ b/brooklinevoiceapp/mycity/test/integration_tests/test_polling_stations_intent.py @@ -0,0 +1,37 @@ +""" Integration tests for PollingStationIntent """ +import mycity.test.test_constants as test_constants +import mycity.test.integration_tests.intent_base_case as base_case +import mycity.test.integration_tests.intent_test_mixins as mix_ins +import mycity.intents.polling_stations_intent as polling_intent +import mycity.intents.intent_constants as intent_constants +import copy + +FEATURES=polling_intent.FEATURES_PATH +ATTRIBUTES=polling_intent.ATTRIBUTES_PATH +NAME=polling_intent.NAME_PATH + +class PollingStationsTestCase(mix_ins.RepromptTextTestMixIn, + mix_ins.CardTitleTestMixIn, + base_case.IntentBaseCase): + + intent_to_test = "PollingStationIntent" + expected_title = polling_intent.CARD_TITLE_POLLING + returns_reprompt_text = False + + def setUp(self): + super().setUp() + + # Patch requests.get in PollingStationIntent + self.mock_requests(get_data=copy.deepcopy(test_constants.GET_ADDRESS_CANDIDATES_API_MOCK), + post_data=copy.deepcopy(test_constants.GET_POLLING_LOCATIONS_API_MOCK)) + + def test_response_contains_polling_first_station_name(self): + first_station_name = test_constants.GET_POLLING_LOCATIONS_API_MOCK[FEATURES][0][ATTRIBUTES][NAME] + response = self.controller.on_intent(self.request) + self.assertTrue(first_station_name in response.output_speech) + + def test_no_feature_results(self): + self.mock_requests(get_data=copy.deepcopy(test_constants.GET_ADDRESS_CANDIDATES_API_MOCK), + post_data=copy.deepcopy(test_constants.NO_RESPONSE_POLLING_LOCATIONS_API_MOCK)) + response = self.controller.on_intent(self.request) + self.assertEqual(response.output_speech, intent_constants.NO_RESULTS_RESPONSE) diff --git a/brooklinevoiceapp/mycity/test/test_constants.py b/brooklinevoiceapp/mycity/test/test_constants.py index 8ae4dac..e5e3424 100644 --- a/brooklinevoiceapp/mycity/test/test_constants.py +++ b/brooklinevoiceapp/mycity/test/test_constants.py @@ -207,11 +207,30 @@ } GET_POLLING_LOCATIONS_API_MOCK = { + "displayFieldName": "NAME", + "fieldAliases": { + "OBJECTID": "OBJECTID", + "NAME": "Polling Location", + "POLLINGID": "Precinct", + "FULLADD": "Address", + "CITY": "City", + "STATE": "State", + "OPERHOURS": "Polling Hours", + "HANDICAP": "Handicap Accessible", + "NEXTELECT": "Next Election Date", + "REGDATE": "Voter Registration Deadline", + "CONTACT": "Contact Name", + "PHONE": "Phone", + "EMAIL": "Email", + "LASTUPDATE": "Last Update Date", + "LASTEDITOR": "Last Editor", + "PRECINCT": "PRECINCT" + }, "features": [ { "attributes": { "OBJECTID": 1, - "NAME": "Young Israel of Brookline, 62 Green St (Side En", + "NAME": "Young Israel of Brookline, 62 Green St", "POLLINGID": "8", "FULLADD": "345 HARVARD ST", "CITY": "Brookline", @@ -265,3 +284,25 @@ ] } +NO_RESPONSE_POLLING_LOCATIONS_API_MOCK = { + "displayFieldName": "NAME", + "fieldAliases": { + "OBJECTID": "OBJECTID", + "NAME": "Polling Location", + "POLLINGID": "Precinct", + "FULLADD": "Address", + "CITY": "City", + "STATE": "State", + "OPERHOURS": "Polling Hours", + "HANDICAP": "Handicap Accessible", + "NEXTELECT": "Next Election Date", + "REGDATE": "Voter Registration Deadline", + "CONTACT": "Contact Name", + "PHONE": "Phone", + "EMAIL": "Email", + "LASTUPDATE": "Last Update Date", + "LASTEDITOR": "Last Editor", + "PRECINCT": "PRECINCT" + }, + "features": [] +} diff --git a/brooklinevoiceapp/mycity/test/unit_tests/test_gis_utils.py b/brooklinevoiceapp/mycity/test/unit_tests/test_gis_utils.py index 47c290b..d448609 100644 --- a/brooklinevoiceapp/mycity/test/unit_tests/test_gis_utils.py +++ b/brooklinevoiceapp/mycity/test/unit_tests/test_gis_utils.py @@ -41,8 +41,8 @@ def test_get_trash_day_json(self): def test_get_polling_locations_json(self): mock_nearest_feature_call = mock.MagicMock(return_value=test_constants.GET_POLLING_LOCATIONS_API_MOCK) mock_geocode_call = mock.MagicMock(return_value=test_constants.LOCATION_MOCK) - result = utils.get_nearest_police_station_json(self.test_address, - _get_nearest_feature_json=mock_nearest_feature_call, - _geocode_address=mock_geocode_call) + result = utils.get_polling_locations_json(self.test_address, + _get_nearest_feature_json=mock_nearest_feature_call, + _geocode_address=mock_geocode_call) self.assertEqual(result, test_constants.GET_POLLING_LOCATIONS_API_MOCK) diff --git a/brooklinevoiceapp/mycity/utils/brookline_arcgis_api_utils.py b/brooklinevoiceapp/mycity/utils/brookline_arcgis_api_utils.py index 0e40d29..cbf25c6 100644 --- a/brooklinevoiceapp/mycity/utils/brookline_arcgis_api_utils.py +++ b/brooklinevoiceapp/mycity/utils/brookline_arcgis_api_utils.py @@ -144,9 +144,9 @@ def get_nearest_police_station_json(address: str, -def get_polling_locations(address: str, - _get_nearest_feature_json: callable = get_nearest_feature_json, - _geocode_address: callable = geocode_address) -> object: +def get_polling_locations_json(address: str, + _get_nearest_feature_json: callable = get_nearest_feature_json, + _geocode_address: callable = geocode_address) -> object: """ Queries the Brookline arcgis server for nearby polling stations From cfed98924f1a2aedcd59ca7ace45900c520c5703 Mon Sep 17 00:00:00 2001 From: Jaumb Date: Tue, 16 Jul 2019 19:55:11 -0400 Subject: [PATCH 2/2] end session --- brooklinevoiceapp/mycity/intents/police_station_intent.py | 1 + brooklinevoiceapp/mycity/intents/polling_stations_intent.py | 1 + brooklinevoiceapp/mycity/intents/trash_day_intent.py | 1 + 3 files changed, 3 insertions(+) diff --git a/brooklinevoiceapp/mycity/intents/police_station_intent.py b/brooklinevoiceapp/mycity/intents/police_station_intent.py index 4c7236a..375d6f0 100644 --- a/brooklinevoiceapp/mycity/intents/police_station_intent.py +++ b/brooklinevoiceapp/mycity/intents/police_station_intent.py @@ -37,6 +37,7 @@ def find_closest_police_station(mycity_request): else: response.output_speech = _get_output_speech_for_address(current_address) response.card_title = CARD_TITLE_POLICE_STATION + response.should_end_session = True return response diff --git a/brooklinevoiceapp/mycity/intents/polling_stations_intent.py b/brooklinevoiceapp/mycity/intents/polling_stations_intent.py index b55a1f6..f9125dd 100644 --- a/brooklinevoiceapp/mycity/intents/polling_stations_intent.py +++ b/brooklinevoiceapp/mycity/intents/polling_stations_intent.py @@ -44,6 +44,7 @@ def get_polling_location_info(mycity_request): else: response.output_speech = _get_output_speech_for_address(current_address, mycity_request) response.card_title = CARD_TITLE_POLLING + response.should_end_session = True return response diff --git a/brooklinevoiceapp/mycity/intents/trash_day_intent.py b/brooklinevoiceapp/mycity/intents/trash_day_intent.py index 1691ab3..fd961d8 100644 --- a/brooklinevoiceapp/mycity/intents/trash_day_intent.py +++ b/brooklinevoiceapp/mycity/intents/trash_day_intent.py @@ -38,6 +38,7 @@ def get_trash_pickup_info(mycity_request): else: response.output_speech = _get_output_speech_for_address(current_address) response.card_title = CARD_TITLE_TRASH_DAY + response.should_end_session = True return response