# Creates or updates a Lex V2 QnABot bot # Automatically generates locales as specified by environment var LOCALES - from Cfn parameter. from configparser import DuplicateSectionError import os import os.path import json import time import sys import re #for boto3 path from py_modules root = os.environ["LAMBDA_TASK_ROOT"] + "/py_modules" sys.path.insert(0, root) import boto3 from crhelper import CfnResource helper = CfnResource() clientLEXV2 = boto3.client('lexv2-models') clientIAM = boto3.client('iam') clientTRANSLATE = boto3.client('translate') s3 = boto3.resource('s3') # LEX QNABOT INFO FULFILLMENT_LAMBDA_ARN = os.environ["FULFILLMENT_LAMBDA_ARN"] STACKNAME = os.environ["STACKNAME"] LEXV2_BOT_LOCALE_IDS = os.environ["LOCALES"].replace(' ','').split(",") # ensure en_US is always in the list, and that list elements are unique LEXV2_BOT_LOCALE_IDS.append("en_US") LEXV2_BOT_LOCALE_IDS = list(dict.fromkeys(LEXV2_BOT_LOCALE_IDS)) INTENT_CONFIDENCE_THRESHOLD = 0.8 BOT_NAME = STACKNAME + "_QnaBot" QNA_INTENT = "QnaIntent" QID_INTENT_PREFIX = "QID-INTENT-" QID_SLOTTYPE_PREFIX = "QID-SLOTTYPE-" QNA_SLOT_TYPE = "QnaSlotType" BOT_ALIAS = "live" LEXV2_BOT_DRAFT_VERSION = "DRAFT" LEXV2_TEST_BOT_ALIAS = "TestBotAlias" LEXV2_BOT_LOCALE_VOICES = { "ar_AE": [{ #Arabic (AE) "voiceId": "Zeina", "engine": "standard" }], "de_AT": [{ #German (AT) "voiceId": "Vicki", "engine": "neural" }], "de_DE": [{ #German (DE) "voiceId": "Vicki", "engine": "neural" }], "en_AU": [{ #English (AU) "voiceId": "Olivia", "engine": "neural" }], "en_GB": [{ #English (GB) "voiceId": "Amy", "engine": "neural" }], "en_IN": [{ #English (IN) "voiceId": "Kajal", "engine": "neural" }], "en_US": [{ #English (US) "voiceId": "Joanna", "engine": "neural" }], "en_ZA": [{ #English (ZA) "voiceId": "Ayanda", "engine": "neural" }], "es_419": [{ #Spanish (LATAM) "voiceId": "Mia", "engine": "neural" }], "es_ES": [{ #Spanish (ES) "voiceId": "Lucia", "engine": "neural" }], "es_US": [{ #Spanish (US) "voiceId": "Lupe", "engine": "neural" }], "fi_FI": [{ #Finnish (FI) "voiceId": "Suvi", "engine": "neural" }], "fr_CA": [{ #French (CA) "voiceId": "Gabrielle", "engine": "neural" }], "fr_FR": [{ #French (FR) "voiceId": "Lea", "engine": "neural" }], "hi_IN": [{ #Hindi (IN) "voiceId": "Kajal", "engine": "neural" }], "it_IT": [{ #Italian (IT) "voiceId": "Bianca", "engine": "neural" }], "ja_JP": [{ #Japan (JP) "voiceId": "Takumi", "engine": "neural" }], "ko_KR": [{ #Korean (KR) "voiceId": "Seoyeon", "engine": "neural" }], "nl_NL": [{ #Dutch (NL) "voiceId": "Laura", "engine": "neural" }], "no_NO": [{ #Norwegian (NO) "voiceId": "Ida", "engine": "neural" }], "pl_PL": [{ #Polish (PL) "voiceId": "Ola", "engine": "neural" }], "pt_BR": [{ #Portuguese (BR) "voiceId": "Camila", "engine": "neural" }], "pt_PT": [{ #Portuguese (PT) "voiceId": "Ines", "engine": "neural" }], "sv_SE": [{ #Swedish (SE) "voiceId": "Elin", "engine": "neural" }], "zh_HK": [{ #Cantonese (HK) "voiceId": "Hiujin", "engine": "neural" }], "zh_CN": [{ #Mandarin (PRC) "voiceId": "Zhiyu", "engine": "neural" }] } # if statusFile defined in lambda event, then log build status to specified S3 object # used by getBot API for bot status checks in Designer statusFile={} def status(status): if statusFile: object = s3.Object(statusFile["Bucket"], statusFile["Key"]) result=json.loads(object.get()["Body"].read()) result["status"] = status object.put(Body=json.dumps(result)) print("Status: " + status) def get_qna_V2_slotTypeValues(localeId, utterances): slotTypeValues = [] utterances = translate_list(localeId, utterances) for utterance in utterances: slotTypeValue = { 'sampleValue': { 'value': utterance } } slotTypeValues.append(slotTypeValue) return slotTypeValues def get_qid_V2_slotTypeValues(localeId, slotTypeDef): resolutionStrategyRestrict = slotTypeDef.get('resolutionStrategyRestrict',False) if not resolutionStrategyRestrict: print("Restrict slot resolution is False - translate slotType sample values") else: print("Restrict slot resolution is True - append translated slotType synonyms, do not translate slotType values") v2_slotTypeValues = [] for slotTypeValue in slotTypeDef["slotTypeValues"]: v2_slotTypeValue = {} sampleValue = slotTypeValue['samplevalue'] if not resolutionStrategyRestrict: sampleValue = translate_text(localeId, sampleValue) v2_slotTypeValue = { 'sampleValue': { 'value': sampleValue } } else: synonyms_str = slotTypeValue.get('synonyms',"") synonymValues = synonyms_str.split(",") if synonyms_str else [] # append translated synonyms to original and de-dup synonymValues = list(set(synonymValues + translate_list(localeId, synonymValues))) if synonymValues == []: v2_slotTypeValue = { 'sampleValue': { 'value': sampleValue } } else: v2_slotTypeValue = { 'sampleValue': { 'value': sampleValue }, 'synonyms' : [ {'value': value} for value in synonymValues] } v2_slotTypeValues.append(v2_slotTypeValue) return v2_slotTypeValues def get_slotTypeId(slotTypeName, botId, botVersion, localeId): slotTypeId = None response = clientLEXV2.list_slot_types( botId=botId, botVersion=botVersion, localeId=localeId, filters=[ { 'name': 'SlotTypeName', 'values': [ slotTypeName, ], 'operator': 'EQ' }, ], maxResults=1000 ) if len(response["slotTypeSummaries"]) == 1: slotTypeId = response["slotTypeSummaries"][0]["slotTypeId"] elif len(response["slotTypeSummaries"]) > 1: raise Exception(f"Multiple matching slotTypes for slotTypeName: {slotTypeName}") return slotTypeId def get_slotId(slotName, intentId, botId, botVersion, localeId): slotId = None response = clientLEXV2.list_slots( botId=botId, botVersion=botVersion, localeId=localeId, intentId=intentId, filters=[ { 'name': 'SlotName', 'values': [ slotName, ], 'operator': 'EQ' }, ], maxResults=1000 ) if len(response["slotSummaries"]) == 1: slotId = response["slotSummaries"][0]["slotId"] elif len(response["slotSummaries"]) > 1: raise Exception(f"Multiple matching slots for slotName: {slotName}") return slotId def get_slotIds(intentId, botId, botVersion, localeId): slotId = None response = clientLEXV2.list_slots( botId=botId, botVersion=botVersion, localeId=localeId, intentId=intentId, maxResults=1000 ) slotIds = [slotSummary["slotId"] for slotSummary in response["slotSummaries"] ] return slotIds def delete_slots_for_intent(intentId, botId, botVersion, localeId): slotIds = get_slotIds(intentId, botId, botVersion, localeId) if slotIds: print(f"Deleting slots {slotIds} for intent '{intentId}'") for slotId in slotIds: response = clientLEXV2.delete_slot( slotId=slotId, botId=botId, botVersion=botVersion, localeId=localeId, intentId=intentId ) else: print(f"intent '{intentId}' has no slots") def get_botId(botName): botId = None response = clientLEXV2.list_bots( filters=[ { 'name': 'BotName', 'values': [ botName, ], 'operator': 'EQ' } ], maxResults=1000 ) if len(response["botSummaries"]) == 1: botId = response["botSummaries"][0]["botId"] elif len(response["botSummaries"]) > 1: raise Exception(f"Multiple matching bots for botName: {botName}") return botId def get_intentId(intentName, botId, botVersion, localeId): intentId = None response = clientLEXV2.list_intents( botId=botId, botVersion=botVersion, localeId=localeId, filters=[ { 'name': 'IntentName', 'values': [ intentName, ], 'operator': 'EQ' }, ], maxResults=1000 ) if len(response["intentSummaries"]) == 1: intentId = response["intentSummaries"][0]["intentId"] elif len(response["intentSummaries"]) > 1: raise Exception(f"Multiple matching intents for intentName: {intentName}") return intentId def add_spantag_to_slots(utterance): slots = re.findall(r'({.*?})',utterance) for slot in slots: utterance = utterance.replace(slot, f'{slot}') return utterance def remove_spantag_from_slots(utterance): slots = re.findall(r'({.*?})',utterance) for slot in slots: utterance = utterance.replace(f'{slot}', f" {slot} ") return utterance def translate_text(localeId, text): langCode = localeId.split("_")[0] if len(text) > 1: try: # don't translate slot names text2 = add_spantag_to_slots(text) response = clientTRANSLATE.translate_text( Text=text2, SourceLanguageCode='auto', TargetLanguageCode=langCode ) translatedText = response["TranslatedText"] translatedText = remove_spantag_from_slots(translatedText) except Exception as e: print(f"Auto translation failed for '{text}' - using original. Exception: {e}") translatedText = text else: print(f"Utterance {text} too short to translate - using original.") translatedText = text print(f"Translated utterance: {text} -> {translatedText}") return translatedText def translate_list(localeId, utterances): translatedUtterances = [] for utterance in utterances: translatedUtterance = translate_text(localeId, utterance) translatedUtterances.append(translatedUtterance) # deduplicate translatedUtterances = list(dict.fromkeys(translatedUtterances)) # remove any empty or whitespace only strings translatedUtterances = [u for u in translatedUtterances if u.strip()] return translatedUtterances def lexV2_qna_slotType(slotTypeName, botId, botVersion, localeId, utterances): print(f"SlotType {slotTypeName}") slotTypeValues = get_qna_V2_slotTypeValues(localeId, utterances) slotTypeId = get_slotTypeId(slotTypeName, botId, botVersion, localeId) slotTypeParams = { "slotTypeName": slotTypeName, "slotTypeValues": slotTypeValues, "valueSelectionSetting": { 'resolutionStrategy': 'OriginalValue' }, "botId": botId, "botVersion": botVersion, "localeId": localeId } if slotTypeId: print(f"Updating SlotType {slotTypeName}") clientLEXV2.update_slot_type(slotTypeId=slotTypeId, **slotTypeParams) else: print(f"Creating SlotType {slotTypeName}") clientLEXV2.create_slot_type(**slotTypeParams) def qid2slotType(qid): return QID_SLOTTYPE_PREFIX + qid.replace(".", "_dot_") def slotType2qid(slotType): return slotType.replace(QID_SLOTTYPE_PREFIX,"").replace("_dot_",".") def lexV2_qid_slotType(qid, botId, botVersion, localeId, slotTypeDef): slotTypeName = qid2slotType(qid) print(f"SlotType '{slotTypeName}' from QID '{qid}'") slotTypeValues=get_qid_V2_slotTypeValues(localeId, slotTypeDef) slotTypeId = get_slotTypeId(slotTypeName, botId, botVersion, localeId) resolutionStrategyRestrict = slotTypeDef.get("resolutionStrategyRestrict", False) resolutionStrategy = 'TopResolution' if resolutionStrategyRestrict else 'OriginalValue' slotTypeParams = { "slotTypeName": slotTypeName, "description": slotTypeDef.get("descr",slotTypeName), "slotTypeValues": slotTypeValues, "valueSelectionSetting": { 'resolutionStrategy': resolutionStrategy }, "botId": botId, "botVersion": botVersion, "localeId": localeId } if slotTypeId: print(f"Updating SlotType {slotTypeName}") clientLEXV2.update_slot_type(slotTypeId=slotTypeId, **slotTypeParams) else: print(f"Creating SlotType {slotTypeName}") clientLEXV2.create_slot_type(**slotTypeParams) def get_qid_slotTypes_to_delete(slotTypes, botId, botVersion, localeId): response = clientLEXV2.list_slot_types( botId=botId, botVersion=botVersion, localeId=localeId, filters=[ { 'name': 'SlotTypeName', 'values': [ QID_SLOTTYPE_PREFIX, ], 'operator': 'CO' }, ], maxResults=1000 ) slotTypes_to_delete = [] for slotTypeSummary in response["slotTypeSummaries"]: slotTypeName = slotTypeSummary["slotTypeName"] slotTypeId = slotTypeSummary["slotTypeId"] qid = slotType2qid(slotTypeName) if qid not in slotTypes: print(f"QID Slot type '{slotTypeName} : {slotTypeId}' (QID '{qid}') has no corresponding slotType QIDS, and will be deleted.") slotTypes_to_delete.append(slotTypeId) return slotTypes_to_delete def lexV2_qid_delete_slotTypes(slotTypes, botId, botVersion, botLocaleId): slotTypes_to_delete = get_qid_slotTypes_to_delete(slotTypes, botId, botVersion, botLocaleId) for slotType in slotTypes_to_delete: response = clientLEXV2.delete_slot_type( slotTypeId=slotType, botId=botId, botVersion=botVersion, localeId=botLocaleId ) print(f'Deleted slot type - Id: {slotType}') def lexV2_intent_slot(slotName, intentId, slotTypeId, slotSampleUtterances, botId, botVersion, localeId, slotRequired=None, slotElicitationPrompt=None): # if a slotRequired is provided, assume slot is required slotConstraint = "Required" if slotRequired else "Optional" slotElicitationPrompt = slotElicitationPrompt or "What is the question?" valueElicitationSetting = { "promptSpecification": { "messageGroups": [ { "message": { "plainTextMessage": { "value": slotElicitationPrompt } } } ], "maxRetries": 4 }, "slotConstraint": slotConstraint } if slotSampleUtterances: sampleUtterances = slotSampleUtterances.split(",") valueElicitationSetting["sampleUtterances"] = [ {"utterance": utterance} for utterance in sampleUtterances] slotParams = { "slotName": slotName, "slotTypeId": slotTypeId, "valueElicitationSetting": valueElicitationSetting, "botId": botId, "botVersion": botVersion, "localeId": localeId, "intentId": intentId } slotId = get_slotId(slotName, intentId, botId, botVersion, localeId) if slotId: print(f"Updating slot: {slotName}, slotId {slotId}, type {slotTypeId} for intent {intentId}") response = clientLEXV2.update_slot(slotId=slotId, **slotParams) print(f'Updated slot - Id: {slotId}') else: print(f"Creating slot: {slotName}, type {slotTypeId} for intent {intentId}") response = clientLEXV2.create_slot(**slotParams) slotId = response["slotId"]; print(f'Created slot - Id: {slotId}') return slotId def lexV2_qna_intent(intentName, slotTypeName, botId, botVersion, localeId): slotName="qnaslot" sampleUtterances = [{f"utterance": f"{{{slotName}}}"}] intentParams = { "intentName": intentName, "description": f"({localeId}) Default QnABot intent.", "sampleUtterances":sampleUtterances, "fulfillmentCodeHook": {'enabled': True}, "botId": botId, "botVersion": botVersion, "localeId": localeId } intentId = get_intentId(intentName, botId, botVersion, localeId) slotTypeId = get_slotTypeId(slotTypeName, botId, botVersion, localeId) slotSampleUtterances = None if intentId: print(f"Updating intent: {intentName}, intentId {intentId}") response = clientLEXV2.update_intent(intentId=intentId, **intentParams) print(f'Updated intent - Id: {intentId}') else: print(f"Creating intent: {intentName}") response = clientLEXV2.create_intent(**intentParams) intentId = response["intentId"]; print(f'Created intent - Id: {intentId}') slotId = lexV2_intent_slot(slotName, intentId, slotTypeId, slotSampleUtterances, botId, botVersion, localeId) print(f'Updating intent to add slot priority - intentId: {intentId}, slotId {slotId}') response = clientLEXV2.update_intent( **intentParams, intentId=intentId, slotPriorities=[ { 'priority': 1, 'slotId': slotId } ] ) intentId = response["intentId"]; print(f'Updated intent to add slot priority - intentId: {intentId}, slotId {slotId}') def qid2intentname(qid): return QID_INTENT_PREFIX + qid.replace(".", "_dot_") def intentname2qid(intentname): return intentname.replace(QID_INTENT_PREFIX,"").replace("_dot_",".") def lexV2_qid_intent(qid, utterances, slots, slotTypes, botId, botVersion, localeId): # make intentName from qid - replace . characters (not allowed in intent name) intentName = qid2intentname(qid) print(f"Creating intent: {intentName} for Qid: {qid}") utterances = translate_list(localeId, utterances) sampleUtterances = [{"utterance": q} for q in utterances] intentParams = { "intentName": intentName, "description": f"({localeId}) Intent for QnABot QID: '{qid}'", "sampleUtterances":sampleUtterances, "dialogCodeHook": {'enabled': True}, "fulfillmentCodeHook": {'enabled': True}, "botId": botId, "botVersion": botVersion, "localeId": localeId } intentId = get_intentId(intentName, botId, botVersion, localeId) if intentId: print(f"Updating intent: {intentName}, intentId {intentId}") response = clientLEXV2.update_intent(intentId=intentId, **intentParams) print(f'Updated intent - Id: {intentId}') else: print(f"Creating intent: {intentName}") response = clientLEXV2.create_intent(**intentParams) intentId = response["intentId"]; print(f'Created intent - Id: {intentId}') intentId = response["intentId"]; slotPriorities = [] # delete any/all exiting slots delete_slots_for_intent(intentId,botId,botVersion,localeId) # create new slots for slot in slots: slotName = slot["slotName"] slotType = slot["slotType"] #get slotRequired value if exists, otherwise return None slotRequired = slot.get("slotRequired", None) slotSampleUtterances = slot.get("slotSampleUtterances") slotTypeId = None if "AMAZON." in slotType: # Built-in type slotTypeId = slotType elif slotType in slotTypes: # Custom type defined in QnABot Content Designer - look up slotTypeId from qid mapped name. slotTypeId = get_slotTypeId(qid2slotType(slotType), botId, botVersion, localeId) else: # Custom type not defined in QnABot Content Designer - look up slotTypeId from provided name slotTypeId = get_slotTypeId(slotType, botId, botVersion, localeId) if not slotTypeId: raise ValueError(f"ERROR: Slot type '{slotType}' used in Qid '{qid}' is not a built-in or existing custom slot type (locale={localeId})") prompt = translate_text(localeId, slot["slotPrompt"]) slotId = lexV2_intent_slot(slotName, intentId, slotTypeId, slotSampleUtterances, botId, botVersion, localeId, slotRequired=slotRequired, slotElicitationPrompt=prompt) slotPriorities.append({ 'priority': len(slotPriorities) + 1, 'slotId': slotId }) print(f'Updating intent to add slot priorities - intentId: {intentId}') response = clientLEXV2.update_intent( **intentParams, intentId=intentId, slotPriorities=slotPriorities ) intentId = response["intentId"]; print(f'Updated intent to add slot priorities - intentId: {intentId}') def get_qid_intents_to_delete(intents, botId, botVersion, localeId): response = clientLEXV2.list_intents( botId=botId, botVersion=botVersion, localeId=localeId, filters=[ { 'name': 'IntentName', 'values': [ QID_INTENT_PREFIX, ], 'operator': 'CO' }, ], maxResults=1000 ) intents_to_delete = [] for intentSummary in response["intentSummaries"]: intentname = intentSummary["intentName"] intentid = intentSummary["intentId"] qid = intentname2qid(intentname) if qid not in intents: print(f"QID Intent '{intentname} : {intentid}' (QID '{qid}') has no corresponding lex enabled QIDs, and will be deleted.") intents_to_delete.append(intentid) return intents_to_delete def lexV2_qid_delete_intents(intents, botId, botVersion, botLocaleId): intents_to_delete = get_qid_intents_to_delete(intents, botId, botVersion, botLocaleId) for intent in intents_to_delete: response = clientLEXV2.delete_intent( intentId=intent, botId=botId, botVersion=botVersion, localeId=botLocaleId ) print(f'Deleted intent - Id: {intent}') def lexV2_genesys_intent(botId, botVersion, localeId): intentName = "GenesysInitialIntent" intentParams = { "intentName": intentName, "description": f"({localeId}) Intent used only by Genesys Cloud CX integration", "botId": botId, "botVersion": botVersion, "localeId": localeId, "intentClosingSetting":{ 'closingResponse': { 'messageGroups': [ { 'message': { 'ssmlMessage': { 'value': '' }, }, }, ], 'allowInterrupt': True }, 'active': True }, } intentId = get_intentId(intentName, botId, botVersion, localeId) if intentId: print(f"Updating intent: {intentName}, intentId {intentId}") response = clientLEXV2.update_intent(intentId=intentId, **intentParams) print(f'Updated intent - Id: {intentId}') else: print(f"Creating intent: {intentName}") response = clientLEXV2.create_intent(**intentParams) intentId = response["intentId"]; print(f'Created intent - Id: {intentId}') def lexV2_fallback_intent(botId, botVersion, localeId): intentName = "FallbackIntent" intentId = get_intentId(intentName, botId, botVersion, localeId) intentParams = { "intentId": intentId, "intentName": intentName, "parentIntentSignature": "AMAZON.FallbackIntent", "fulfillmentCodeHook": {'enabled': True}, "botId": botId, "botVersion": botVersion, "localeId": localeId } print(f"Updating fallback intent {intentId} to set Lambda for fulfilment.") clientLEXV2.update_intent(**intentParams) print(f'Updated fallback intent - Id: {intentId}') def get_bot_locale_status(botId, botVersion, localeId): response = clientLEXV2.describe_bot_locale( botId=botId, botVersion=botVersion, localeId=localeId ) botLocaleStatus = response["botLocaleStatus"] print(f"Bot locale status: {localeId} => {botLocaleStatus}") return botLocaleStatus def wait_for_lexV2_qna_locale(botId, botVersion, localeId): botLocaleStatus = get_bot_locale_status(botId, botVersion, localeId) while botLocaleStatus not in ["NotBuilt","Built"]: time.sleep(5) botLocaleStatus = get_bot_locale_status(botId, botVersion, localeId) if botLocaleStatus not in ["NotBuilt","Built","Creating","Building","ReadyExpressTesting"]: raise Exception(f"Invalid botLocaleStatus for locale '{localeId}'): '{botLocaleStatus}'. Check for build errors in LexV2 console for bot '{BOT_NAME}'") print(f"Bot localeId {localeId}: {botLocaleStatus}") return botLocaleStatus def localeIdExists(botId, botVersion, localeId): intentId = None try: response = clientLEXV2.describe_bot_locale( botId=botId, botVersion=botVersion, localeId=localeId ) return True except: return False def lexV2_qna_locale(botId, botVersion, localeId, voiceId, engine): if not localeIdExists(botId, botVersion, localeId): response = clientLEXV2.create_bot_locale( botId=botId, botVersion=botVersion, localeId=localeId, nluIntentConfidenceThreshold=INTENT_CONFIDENCE_THRESHOLD, voiceSettings={ 'voiceId': voiceId, 'engine': engine } ) wait_for_lexV2_qna_locale(botId, botVersion, localeId) return localeId def get_or_create_lexV2_service_linked_role(botName): # Does role already exist? rolenamePrefix = "AWSServiceRoleForLexV2Bots" rolenameSuffix = botName[0:(63-len(rolenamePrefix))] # max len 64 roleName = f"{rolenamePrefix}_{rolenameSuffix}" print(roleName) try: response = clientIAM.get_role( RoleName=roleName ) roleArn = response["Role"]["Arn"] except: response = clientIAM.create_service_linked_role( AWSServiceName='lexv2.amazonaws.com', Description=f'Service role for QnABot - {botName}', CustomSuffix=rolenameSuffix ) roleArn = response["Role"]["Arn"] return roleArn def get_bot_status(botId): response = clientLEXV2.describe_bot(botId=botId) botStatus = response["botStatus"] print(f"Bot status: {botStatus}") return botStatus def wait_for_lexV2_qna_bot(botId): botStatus = get_bot_status(botId) while botStatus != 'Available': time.sleep(5) botStatus = get_bot_status(botId) if botStatus not in ["Available","Creating","Versioning"]: raise Exception(f"Invalid botStatus: {botStatus}") return botStatus def lexV2_qna_bot(botName): botId = get_botId(botName) if not botId: print(f"Creating bot {botName}") response = clientLEXV2.create_bot( botName=botName, description='QnABot Lex V2', roleArn=get_or_create_lexV2_service_linked_role(botName), dataPrivacy={ 'childDirected': False }, idleSessionTTLInSeconds=300 ) botId = response["botId"] print(f"Creating bot {botName} with ID {botId}") else: print(f"Bot {botName} exists with ID {botId}") wait_for_lexV2_qna_bot(botId) return botId def get_bot_version_status(botId, botVersion): response = clientLEXV2.describe_bot_version( botId=botId, botVersion=botVersion ) botStatus = response["botStatus"] print(f"Bot status: {botStatus}") return botStatus def wait_for_lexV2_qna_version(botId, botVersion): botStatus = get_bot_version_status(botId, botVersion) while botStatus != 'Available': time.sleep(5) botStatus = get_bot_version_status(botId, botVersion) if botStatus not in ["Available","Creating","Versioning"]: raise Exception(f"Invalid botStatus: {botStatus}") return botStatus def lexV2_qna_version(botId, botDraftVersion, botLocaleIds): botVersion = None print(f"Creating bot version from {botDraftVersion}") botVersionLocaleSpecification = {} for botLocaleId in botLocaleIds: botVersionLocaleSpecification[botLocaleId] = { 'sourceBotVersion': botDraftVersion } response = clientLEXV2.create_bot_version( botId=botId, botVersionLocaleSpecification=botVersionLocaleSpecification ) botVersion = response["botVersion"] botStatus = response["botStatus"] print(f"Created bot version {botVersion} - {botStatus}") time.sleep(5) wait_for_lexV2_qna_version(botId, botVersion) return botVersion def get_bot_aliasId(botId, botAliasName): botAliasId = None response = clientLEXV2.list_bot_aliases( botId=botId, maxResults=1000 ) for alias in response["botAliasSummaries"]: if alias["botAliasName"] == botAliasName: botAliasId = alias["botAliasId"] return botAliasId def get_bot_alias_status(botId, botAliasId): response = clientLEXV2.describe_bot_alias( botId=botId, botAliasId=botAliasId ) botAliasStatus = response["botAliasStatus"] print(f"Bot alias status: {botAliasStatus}") return botAliasStatus def wait_for_lexV2_qna_alias(botId, botAliasId): botAliasStatus = get_bot_alias_status(botId, botAliasId) while botAliasStatus != 'Available': time.sleep(5) botAliasStatus = get_bot_alias_status(botId, botAliasId) if botAliasStatus not in ["Available","Creating","Versioning"]: raise Exception(f"Invalid botStatus: {botAliasStatus}") return botAliasStatus def lexV2_qna_alias(botId, botVersion, botAliasName, botLocaleIds, botFullfillmentLambdaArn): botAliasLocaleSettings = {} for botLocaleId in botLocaleIds: botAliasLocaleSettings[botLocaleId] = { 'enabled': True, 'codeHookSpecification': { 'lambdaCodeHook': { 'lambdaARN': botFullfillmentLambdaArn, 'codeHookInterfaceVersion': '1.0' } } } botAliasId = get_bot_aliasId(botId, botAliasName) aliasParams = { 'botAliasName':botAliasName, 'botVersion':botVersion, 'botAliasLocaleSettings': botAliasLocaleSettings, 'sentimentAnalysisSettings':{ 'detectSentiment': False }, 'botId':botId } if not botAliasId: print(f"Creating botAlias {botAliasName} for bot {botId} version {botVersion}") response = clientLEXV2.create_bot_alias(**aliasParams) botAliasId = response["botAliasId"] print(f"Creates bot alias {botAliasName} with ID {botAliasId}") else: print(f"Updating botAlias {botAliasName} for bot {botId} version {botVersion}") response = clientLEXV2.update_bot_alias(**aliasParams, botAliasId=botAliasId) botAliasId = response["botAliasId"] print(f"Updated bot alias {botAliasName} with ID {botAliasId}") wait_for_lexV2_qna_alias(botId, botAliasId) return botAliasId def build_lexV2_qna_bot_locale(botId, botVersion, localeId): print(f"Building bot: {botId}, {botVersion}, {localeId}") response = clientLEXV2.build_bot_locale( botId=botId, botVersion=botVersion, localeId=localeId ) def lexV2_qna_delete_old_versions(botId): response = clientLEXV2.list_bot_versions( botId=botId, sortBy={ 'attribute': 'BotVersion', 'order': 'Ascending' }, maxResults=1000 ) botVersionSummaries = response["botVersionSummaries"] if len(botVersionSummaries) > 3: botVersionSummariesToDelete = botVersionSummaries[:-3] # keep highest 2 versions for botVersionSummary in botVersionSummariesToDelete: botVersion = botVersionSummary["botVersion"] print(f"Deleting BotVersion: {botVersion}") response = clientLEXV2.delete_bot_version( botId=botId, botVersion=botVersion, skipResourceInUseCheck=True ) def batches(lst, n): """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): yield lst[i:i + n] def get_bot_info(): botId = get_botId(BOT_NAME) bot_aliasId = get_bot_aliasId(botId, BOT_ALIAS) result = { "botName": BOT_NAME, "botId": get_botId(BOT_NAME), "botAlias": BOT_ALIAS, "botAliasId": bot_aliasId, "botIntent": QNA_INTENT, "botIntentFallback": "FallbackIntent", "botLocaleIds": ",".join(LEXV2_BOT_LOCALE_IDS) } return result def build_all(intents, slotTypes={}): status("Rebuilding bot") botId = lexV2_qna_bot(BOT_NAME) # create or update bot for each locale # process locales in batches to staty with service limit bot-locale-builds-per-account (default 5) botlocaleIdBatches = list(batches(LEXV2_BOT_LOCALE_IDS,5)) for botlocaleIdBatch in botlocaleIdBatches: print("Batch: " + str(botlocaleIdBatch)) for botLocaleId in botlocaleIdBatch: status("Updating bot locale: " + botLocaleId) lexV2_qna_locale(botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId, voiceId=LEXV2_BOT_LOCALE_VOICES[botLocaleId][0]["voiceId"], engine=LEXV2_BOT_LOCALE_VOICES[botLocaleId][0]["engine"]) lexV2_fallback_intent(botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) lexV2_genesys_intent(botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) for qid in slotTypes: lexV2_qid_slotType(qid, botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId, slotTypeDef=slotTypes[qid]) for qid in intents: utterances = intents[qid]["utterances"] if qid == QNA_INTENT: # Standard QnABot slot type and intent lexV2_qna_slotType(QNA_SLOT_TYPE, botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId, utterances=utterances) lexV2_qna_intent(QNA_INTENT, QNA_SLOT_TYPE, botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) else: # Custom intent - one intent per Qid slots = intents[qid]["slots"] if "slots" in intents[qid] else [] lexV2_qid_intent(qid, utterances, slots, slotTypes, botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) # Delete QID mapped intents that are not in the current list lexV2_qid_delete_intents(intents, botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) #delete slot_types (QID mapped slot types that are not in the current list) after all referenced intents have been deleted. for botLocaleId in botlocaleIdBatch: # Delete QID mapped slot types that are not in the current list lexV2_qid_delete_slotTypes(slotTypes, botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) status("Rebuilding bot locales: " + str(LEXV2_BOT_LOCALE_IDS)) for botLocaleId in botlocaleIdBatch: build_lexV2_qna_bot_locale(botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) # wait for all locales to build for botLocaleId in botlocaleIdBatch: wait_for_lexV2_qna_locale(botId, LEXV2_BOT_DRAFT_VERSION, botLocaleId) # create new bot version and update alias status("Building new bot version") botVersion = lexV2_qna_version(botId, LEXV2_BOT_DRAFT_VERSION, LEXV2_BOT_LOCALE_IDS) lexV2_qna_alias(botId, LEXV2_BOT_DRAFT_VERSION, LEXV2_TEST_BOT_ALIAS, LEXV2_BOT_LOCALE_IDS, FULFILLMENT_LAMBDA_ARN) botAliasId = lexV2_qna_alias(botId, botVersion, BOT_ALIAS, LEXV2_BOT_LOCALE_IDS, FULFILLMENT_LAMBDA_ARN) # keep only the most recent bot versions status("Deleting old bot version(s)") lexV2_qna_delete_old_versions(botId) # return bot ids result = get_bot_info() status("READY") return result def delete_all(): botId = get_botId(BOT_NAME) response = None if botId: response = clientLEXV2.delete_bot( botId=botId, skipResourceInUseCheck=True ) return response def process_slotTypes(items): slotTypes = {} for item in items: slotTypes[item["qid"]] = item["slotType"] return slotTypes def duplicate_utterances(items): qna_intent_utterances = {} qid_intent_utterances = {} dup_utterances = {} dups = None for item in items: for utterance in item["qna"]["q"]: # get processed version of utterance with slot definitions replaced by lex slot references utterance = utterance.lower() if item["qna"]["enableQidIntent"]: if utterance not in qid_intent_utterances: qid_intent_utterances[utterance] = [item["qid"]] else: qid_intent_utterances[utterance].append(item["qid"]) else: if utterance not in qna_intent_utterances: qna_intent_utterances[utterance] = [item["qid"]] else: qna_intent_utterances[utterance].append(item["qid"]) # We only care about duplicates in Lex mapped qids. Others are mapped to QNA_INTENT and deduplicated. for utterance in qid_intent_utterances: if utterance in qna_intent_utterances: qid_intent_utterances[utterance].append(qna_intent_utterances[utterance]) if len(qid_intent_utterances[utterance]) > 1: dup_utterances[utterance] = qid_intent_utterances[utterance] if dup_utterances: dups = "Duplicate questions not allowed in QIDs exported to Lex" for dup_utterance in dup_utterances: dups += f", '{dup_utterance}' in QIDs {dup_utterances[dup_utterance]}" return dups def validate_slots(intents): bad_slots = {} msg = None for qid in intents: slot_dict = {} if "slots" in intents[qid]: for slot in intents[qid]["slots"]: slotname = slot["slotName"] slot_dict[slotname] = True print(f"{slot_dict}") print(f"{intents[qid]}") for utterance in intents[qid]["utterances"]: slotnames = re.findall(r'{(.*?)}',utterance) for slot in slotnames: if slot not in slot_dict: bad_slots[qid] = bad_slots.get(qid,[]) + [slot] if bad_slots: msg = "Undefined slot reference in QID" for qid in bad_slots: msg += f", '{qid}' {bad_slots[qid]}" return msg def process_intents(items): # initialise intents dict intents = { QNA_INTENT: { "utterances":set() } } # build intents with set of unique utterances per intent for item in items: if item["qna"]["enableQidIntent"]: # QID gets its own Lex intent intents[item["qid"]] = {"utterances":set(item["qna"]["q"])} if "slots" in item["qna"]: intents[item["qid"]]["slots"] = item["qna"]["slots"] else: # Add QID utterances to default QnABot intent intents[QNA_INTENT]["utterances"].update(item["qna"]["q"]) # Need at least 1 utterance for default QNA_INTENT if len(intents[QNA_INTENT]["utterances"]) < 1: print(f"Intent {QNA_INTENT} has no utterances.. inserting dummy utterance") intents[QNA_INTENT]["utterances"] = set(["dummy utterance"]) # validate slots (if any) for each intent dups = duplicate_utterances(items) if dups: raise ValueError(dups) bad_slots = validate_slots(intents) if bad_slots: raise ValueError(bad_slots) return intents # cfnHelper functions @helper.create def create_bot(event, _): utterances = event["ResourceProperties"]["utterances"] # map all default utterances to standard Qna intent intents = {QNA_INTENT: {"utterances":utterances}} result = build_all(intents) helper.Data.update(result) @helper.update def update_bot(event, _): print("Cloudformation update - make no changes to existing bot") result = get_bot_info() helper.Data.update(result) @helper.delete def delete_bot(event, _): delete_all() # handler determines in function if called from CFN, allowing fn to be used # as either a Cfn custom resource or not. def handler(event, context): global statusFile if 'ResourceProperties' in event: print("Function called from CloudFormation: " + json.dumps(event)) helper(event, context) else: print("Function not called from CloudFormation: " + json.dumps(event)) try: statusFile = event["statusFile"] items = event["items"] slotType_items = [i for i in items if i["type"]=="slottype"] slotTypes = process_slotTypes(slotType_items) qna_items = [i for i in items if i["type"]=="qna"] intents = process_intents(qna_items) result = build_all(intents, slotTypes) print("LexV2 bot info: " + json.dumps(result)) except Exception as e: result = "FAILED: " + str(e) status(result) raise # for testing on terminal if __name__ == "__main__": items = [ {"qid":QNA_INTENT, "type":"qna", "qna":{"enableQidIntent": True, "q":["what is the capital city of France?", "How great is Q and A bot?"]}}, {"qid":"1.CustomIntent.test", "type":"qna", "qna":{"enableQidIntent": True, "q":["What is your address?", "What is your phone number?"]}}, {"qid":"2.CustomIntent.test", "type":"qna", "qna":{"enableQidIntent": True, "q":["What is your name?", "What are you called?"]}}, {"qid":"3.CustomIntent.test", "type":"qna", "qna":{"enableQidIntent": True, "q":["What are your opening hours?", "How do I contact you?"]}}, {"qid":"4.CustomIntent.test", "type":"qna", "qna":{"enableQidIntent": True, "q":["My name is {firstname}"], "slots":[{"slotRequired": True,"slotName": "firstname","slotType": "AMAZON.FirstName", "slotPrompt": "What is your first name?"}]}}, {"qid":"5.CustomIntent.test", "type":"qna", "qna":{"enableQidIntent": True, "q":["My course is {coursename}"], "slots":[{"slotRequired": True,"slotName": "coursename","slotType": "Course", "slotPrompt": "What is your course name?"}]}}, {"qid": "Course", "type":"slottype", "slotType": {"descr": "Course Name","resolutionStrategyRestrict": True,"slotTypeValues": [{"samplevalue": "Chemistry","synonyms": "Chem"}],}}, ] event = { "statusFile":None, "items": items } result = handler(event,{}) #result = delete_all() print(result)