UNPKG

botbuilder-dialogs-adaptive

Version:

Rule system for the Microsoft BotBuilder dialog system.

1,198 lines (1,069 loc) • 68.5 kB
/** * @module botbuilder-dialogs-adaptive */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { BoolExpression, BoolExpressionConverter, IntExpression } from 'adaptive-expressions'; import { Activity, ActivityTypes, getTopScoringIntent, RecognizerResult, StringUtils, TurnContext, telemetryTrackDialogView, } from 'botbuilder'; import { Converter, ConverterFactory, Dialog, DialogConfiguration, DialogContainer, DialogContext, DialogDependencies, DialogEvent, DialogInstance, DialogPath, DialogReason, DialogSet, DialogState, DialogTurnResult, DialogTurnStatus, Recognizer, TurnPath, } from 'botbuilder-dialogs'; import cloneDeep from 'lodash/cloneDeep'; import isEmpty from 'lodash/isEmpty'; import isEqual from 'lodash/isEqual'; import { ActionContext } from './actionContext'; import { AdaptiveDialogState } from './adaptiveDialogState'; import { AdaptiveEvents } from './adaptiveEvents'; import { OnCondition, OnIntent } from './conditions'; import { DialogSetConverter, LanguageGeneratorConverter, RecognizerConverter } from './converters'; import { EntityAssignment } from './entityAssignment'; import { EntityAssignmentComparer } from './entityAssignmentComparer'; import { EntityAssignments } from './entityAssignments'; import { EntityInfo, NormalizedEntityInfos } from './entityInfo'; import { LanguageGenerator } from './languageGenerator'; import { languageGeneratorKey } from './languageGeneratorExtensions'; import { BoolProperty } from './properties'; import { RecognizerSet } from './recognizers'; import { ValueRecognizer } from './recognizers/valueRecognizer'; import { SchemaHelper } from './schemaHelper'; import { FirstSelector, MostSpecificSelector } from './selectors'; import { TriggerSelector } from './triggerSelector'; import { TelemetryLoggerConstants } from './telemetryLoggerConstants'; // eslint-disable-next-line @typescript-eslint/no-explicit-any function isDialogDependencies(val: any): val is DialogDependencies { return typeof ((val as unknown) as DialogDependencies).getDependencies === 'function'; } export interface AdaptiveDialogConfiguration extends DialogConfiguration { recognizer?: string | Recognizer; generator?: string | LanguageGenerator; triggers?: OnCondition[]; autoEndDialog?: BoolProperty; selector?: TriggerSelector; defaultResultProperty?: string; schema?: unknown; dialogs?: string[] | Dialog[] | DialogSet; } /** * The Adaptive Dialog models conversation using events and events to adapt dynamically to changing conversation flow. */ export class AdaptiveDialog<O extends object = {}> extends DialogContainer<O> implements AdaptiveDialogConfiguration { static $kind = 'Microsoft.AdaptiveDialog'; static conditionTracker = 'dialog._tracker.conditions'; private readonly adaptiveKey = '_adaptive'; private readonly defaultOperationKey = '$defaultOperation'; private readonly expectedOnlyKey = '$expectedOnly'; private readonly entitiesKey = '$entities'; private readonly instanceKey = '$instance'; private readonly operationsKey = '$operations'; private readonly requiresValueKey = '$requiresValue'; private readonly propertyNameKey = 'PROPERTYName'; private readonly propertyEnding = 'Property'; private readonly utteranceKey = 'utterance'; private readonly generatorTurnKey = Symbol('generatorTurn'); private readonly changeTurnKey = Symbol('changeTurn'); private _recognizerSet = new RecognizerSet(); private installedDependencies = false; private needsTracker = false; private dialogSchema: SchemaHelper; private _internalVersion: string; /** * Creates a new `AdaptiveDialog` instance. * * @param dialogId (Optional) unique ID of the component within its parents dialog set. */ constructor(dialogId?: string) { super(dialogId); } /** * Optional. Recognizer used to analyze any message utterances. */ recognizer?: Recognizer; /** * Optional. Language Generator override. */ generator?: LanguageGenerator; /** * Trigger handlers to respond to conditions which modify the executing plan. */ triggers: OnCondition[] = []; /** * Whether to end the dialog when there are no actions to execute. * * @remarks * If true, when there are no actions to execute, the current dialog will end. * If false, when there are no actions to execute, the current dialog will simply end the turn and still be active. * Defaults to a value of true. */ autoEndDialog: BoolExpression = new BoolExpression(true); /** * Optional. The selector for picking the possible events to execute. */ selector: TriggerSelector; /** * The property to return as the result when the dialog ends when there are no more Actions and `AutoEndDialog = true`. * * @remarks * Defaults to a value of `dialog.result`. */ defaultResultProperty = 'dialog.result'; /** * Sets the JSON Schema for the dialog. */ set schema(value: object) { this.dialogSchema = new SchemaHelper(value); } /** * Gets the JSON Schema for the dialog. * * @returns The dialog schema. */ get schema(): object { return this.dialogSchema ? this.dialogSchema.schema : undefined; } /** * @param property The key of the conditional selector configuration. * @returns The converter for the selector configuration. */ getConverter(property: keyof AdaptiveDialogConfiguration): Converter | ConverterFactory { switch (property) { case 'recognizer': return RecognizerConverter; case 'generator': return new LanguageGeneratorConverter(); case 'autoEndDialog': return new BoolExpressionConverter(); case 'dialogs': return DialogSetConverter; default: return super.getConverter(property); } } /** * @protected * Ensures all dependencies for the class are installed. */ protected ensureDependenciesInstalled(): void { if (this.installedDependencies) { return; } this.installedDependencies = true; // Install each trigger actions let id = 0; for (let i = 0; i < this.triggers.length; i++) { const trigger = this.triggers[i]; // Install any dependencies if (isDialogDependencies(trigger)) { trigger.getDependencies().forEach((child) => this.dialogs.add(child)); } if (trigger.runOnce) { this.needsTracker = true; } if (!trigger.priority) { trigger.priority = new IntExpression(id); } if (!trigger.id) { trigger.id = id.toString(); id++; } } if (!this.selector) { // Default to MostSpecificSelector const selector = new MostSpecificSelector(); selector.selector = new FirstSelector(); this.selector = selector; } this.selector.initialize(this.triggers, true); } //--------------------------------------------------------------------------------------------- // Base Dialog Overrides //--------------------------------------------------------------------------------------------- /** * @protected * Gets the internal version string. * @returns Internal version string. */ protected getInternalVersion(): string { if (!this._internalVersion) { // change the container version if any dialogs are added or removed. let version = this.dialogs.getVersion(); // change version if the schema has changed. if (this.schema) { version += JSON.stringify(this.schema); } // change if triggers type/constraint change this.triggers.forEach((trigger): void => { version += trigger.getExpression().toString(); }); this._internalVersion = StringUtils.hash(version); } return this._internalVersion; } protected onComputeId(): string { return 'AdaptiveDialog[]'; } /** * Called when the dialog is started and pushed onto the dialog stack. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param options Optional, initial information to pass to the dialog. * @returns A Promise representing the asynchronous operation. */ async beginDialog(dc: DialogContext, options?: O): Promise<DialogTurnResult> { await this.checkForVersionChange(dc); // Install dependencies on first access this.ensureDependenciesInstalled(); // Initialize dialog state if (options) { // Replace initial activeDialog.State with clone of options dc.activeDialog.state = JSON.parse(JSON.stringify(options)); } // Initialize event counter const dcState = dc.state; if (dcState.getValue(DialogPath.eventCounter) == undefined) { dcState.setValue(DialogPath.eventCounter, 0); } // Initialize list of required properties if (this.dialogSchema && dcState.getValue(DialogPath.requiredProperties) == undefined) { // RequiredProperties control what properties must be filled in. dcState.setValue(DialogPath.requiredProperties, this.dialogSchema.required); } // Initialize change tracker if (this.needsTracker && dcState.getValue(AdaptiveDialog.conditionTracker) == undefined) { this.triggers.forEach((trigger): void => { if (trigger.runOnce && trigger.condition) { const references = trigger.condition.toExpression().references(); const paths = dcState.trackPaths(references); const triggerPath = `${AdaptiveDialog.conditionTracker}.${trigger.id}.`; dcState.setValue(triggerPath + 'paths', paths); dcState.setValue(triggerPath + 'lastRun', 0); } }); } dc.activeDialog.state[this.adaptiveKey] = {}; const properties: { [key: string]: string } = { DialogId: this.id, Kind: 'Microsoft.AdaptiveDialog', context: TelemetryLoggerConstants.DialogStartEvent, }; this.telemetryClient.trackEvent({ name: TelemetryLoggerConstants.GeneratorResultEvent, properties: properties, }); telemetryTrackDialogView(this.telemetryClient, this.id); // Evaluate events and queue up action changes const event: DialogEvent = { name: AdaptiveEvents.beginDialog, value: options, bubble: false }; await this.onDialogEvent(dc, event); // Continue action execution return await this.continueActions(dc); } /** * Called when the dialog is _continued_, where it is the active dialog and the * user replies with a new activity. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @returns A Promise representing the asynchronous operation. */ async continueDialog(dc: DialogContext): Promise<DialogTurnResult> { await this.checkForVersionChange(dc); this.ensureDependenciesInstalled(); // Continue action execution return await this.continueActions(dc); } /** * Called when the dialog is ending. * * @param turnContext The context object for this turn. * @param instance State information associated with the instance of this dialog on the dialog stack. * @param reason Reason why the dialog ended. * @returns A Promise representing the asynchronous operation. */ async endDialog(turnContext: TurnContext, instance: DialogInstance, reason: DialogReason): Promise<void> { const properties: { [key: string]: string } = { DialogId: this.id, Kind: 'Microsoft.AdaptiveDialog', }; if (reason === DialogReason.cancelCalled) { this.telemetryClient.trackEvent({ name: TelemetryLoggerConstants.GeneratorResultEvent, properties: { ...properties, context: TelemetryLoggerConstants.DialogCancelEvent }, }); } else if (reason === DialogReason.endCalled) { this.telemetryClient.trackEvent({ name: TelemetryLoggerConstants.GeneratorResultEvent, properties: { ...properties, context: TelemetryLoggerConstants.CompleteEvent }, }); } await super.endDialog(turnContext, instance, reason); } /** * @protected * Called before an event is bubbled to its parent. * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param event The [DialogEvent](xref:botbuilder-dialogs.DialogEvent) being raised. * @returns Whether the event is handled by the current dialog and further processing should stop. */ protected async onPreBubbleEvent(dc: DialogContext, event: DialogEvent): Promise<boolean> { const actionContext = this.toActionContext(dc); // Process event and queue up any potential interruptions return await this.processEvent(actionContext, event, true); } /** * @protected * Called after an event was bubbled to all parents and wasn't handled. * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @param event The [DialogEvent](xref:botbuilder-dialogs.DialogEvent) being raised. * @returns Whether the event is handled by the current dialog and further processing should stop. */ protected async onPostBubbleEvent(dc: DialogContext, event: DialogEvent): Promise<boolean> { const actionContext = this.toActionContext(dc); // Process event and queue up any potential interruptions return await this.processEvent(actionContext, event, false); } /** * Called when a child dialog completed its turn, returning control to this dialog. * * @param dc The dialog context for the current turn of the conversation. * @param _reason Reason why the dialog resumed. * @param _result Optional, value returned from the dialog that was called. * The type of the value returned is dependent on the child dialog. * @returns A Promise representing the asynchronous operation. */ async resumeDialog(dc: DialogContext, _reason?: DialogReason, _result?: any): Promise<DialogTurnResult> { await this.checkForVersionChange(dc); // Containers are typically leaf nodes on the stack but the dev is free to push other dialogs // on top of the stack which will result in the container receiving an unexpected call to // resumeDialog() when the pushed on dialog ends. // To avoid the container prematurely ending we need to implement this method and simply // ask our inner dialog stack to re-prompt. await this.repromptDialog(dc.context, dc.activeDialog); return Dialog.EndOfTurn; } /** * Reprompts the user. * * @param context The context object for the turn. * @param instance Current state information for this dialog. * @returns A Promise representing the asynchronous operation. */ async repromptDialog(context: DialogContext | TurnContext, instance: DialogInstance): Promise<void> { if (context instanceof DialogContext) { // Forward to current sequence action const state: AdaptiveDialogState = instance.state[this.adaptiveKey]; if (state && state.actions && state.actions.length > 0) { // we need to mockup a DialogContext so that we can call RepromptDialog // for the active step const childContext = this.createChildContext(context); await childContext.repromptDialog(); } } else { await super.repromptDialog(context, instance); } } /** * Creates a child [DialogContext](xref:botbuilder-dialogs.DialogContext) for the given context. * * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @returns The child [DialogContext](xref:botbuilder-dialogs.DialogContext) or null if no [AdaptiveDialogState.actions](xref:botbuilder-dialogs-adaptive.AdaptiveDialogState.actions) are found for the given context. */ createChildContext(dc: DialogContext): DialogContext { const activeDialogState = dc.activeDialog.state; const state: AdaptiveDialogState = activeDialogState[this.adaptiveKey]; if (!state) { activeDialogState[this.adaptiveKey] = { actions: [] }; } else if (state.actions?.length > 0) { const childContext = new DialogContext(this.dialogs, dc, state.actions[0]); this.onSetScopedServices(childContext); return childContext; } return undefined; } /** * Gets [Dialog](xref:botbuilder-dialogs.Dialog) enumerated dependencies. * * @returns [Dialog](xref:botbuilder-dialogs.Dialog)'s enumerated dependencies. */ getDependencies(): Dialog[] { this.ensureDependenciesInstalled(); return []; } //--------------------------------------------------------------------------------------------- // Event Processing //--------------------------------------------------------------------------------------------- /** * @protected * Event processing implementation. * @param actionContext The [ActionContext](xref:botbuilder-dialogs-adaptive.ActionContext) for the current turn of conversation. * @param dialogEvent The [DialogEvent](xref:botbuilder-dialogs.DialogEvent) being raised. * @param preBubble A flag indicator for preBubble processing. * @returns A Promise representation of a boolean indicator or the result. */ protected async processEvent( actionContext: ActionContext, dialogEvent: DialogEvent, preBubble: boolean ): Promise<boolean> { // Save into turn actionContext.state.setValue(TurnPath.dialogEvent, dialogEvent); let activity = actionContext.state.getValue<Activity>(TurnPath.activity); // some dialogevents get promoted into turn state for general access outside of the dialogevent. // This allows events to be fired (in the case of ChooseIntent), or in interruption (Activity) // Triggers all expressed against turn.recognized or turn.activity, and this mapping maintains that // any event that is emitted updates those for the rest of rule evaluation. switch (dialogEvent.name) { case AdaptiveEvents.recognizedIntent: { // we have received a RecognizedIntent event // get the value and promote to turn.recognized, topintent, topscore and lastintent const recognizedResult = actionContext.state.getValue<RecognizerResult>( `${TurnPath.dialogEvent}.value` ); const { intent, score } = getTopScoringIntent(recognizedResult); actionContext.state.setValue(TurnPath.recognized, recognizedResult); actionContext.state.setValue(TurnPath.topIntent, intent); actionContext.state.setValue(TurnPath.topScore, score); actionContext.state.setValue(DialogPath.lastIntent, intent); // process entities for ambiguity processing (We do this regardless of who handles the event) this.processEntities(actionContext, activity); break; } case AdaptiveEvents.activityReceived: // we received an ActivityReceived event, promote the activity into turn.activity actionContext.state.setValue(TurnPath.activity, dialogEvent.value); activity = dialogEvent.value as Activity; break; } this.ensureDependenciesInstalled(); // Count of events processed let count = actionContext.state.getValue(DialogPath.eventCounter); actionContext.state.setValue(DialogPath.eventCounter, ++count); // Look for triggered rule let handled = await this.queueFirstMatch(actionContext); if (handled) { return true; } // Perform default processing if (preBubble) { switch (dialogEvent.name) { case AdaptiveEvents.beginDialog: if (!actionContext.state.getValue(TurnPath.activityProcessed)) { const activityReceivedEvent: DialogEvent = { name: AdaptiveEvents.activityReceived, value: actionContext.context.activity, bubble: false, }; handled = await this.processEvent(actionContext, activityReceivedEvent, true); } break; case AdaptiveEvents.activityReceived: if (activity.type === ActivityTypes.Message) { // Recognize utterance (ignore handled) const recognizeUtteranceEvent: DialogEvent = { name: AdaptiveEvents.recognizeUtterance, value: activity, bubble: false, }; await this.processEvent(actionContext, recognizeUtteranceEvent, true); // Emit leading RecognizedIntent event const recognized = actionContext.state.getValue<RecognizerResult>(TurnPath.recognized); const recognizedIntentEvent: DialogEvent = { name: AdaptiveEvents.recognizedIntent, value: recognized, bubble: false, }; handled = await this.processEvent(actionContext, recognizedIntentEvent, true); } // Has an interruption occurred? // - Setting this value to true causes any running inputs to re-prompt when they're // continued. The developer can clear this flag if they want the input to instead // process the users utterance when its continued. if (handled) { actionContext.state.setValue(TurnPath.interrupted, true); } break; case AdaptiveEvents.recognizeUtterance: if (activity.type === ActivityTypes.Message) { // Recognize utterance const recognizedResult = await this.onRecognize(actionContext, activity); // TODO figure out way to not use turn state to pass this value back to caller. actionContext.state.setValue(TurnPath.recognized, recognizedResult); const { intent, score } = getTopScoringIntent(recognizedResult); actionContext.state.setValue(TurnPath.topIntent, intent); actionContext.state.setValue(TurnPath.topScore, score); actionContext.state.setValue(DialogPath.lastIntent, intent); handled = true; } break; case AdaptiveEvents.repromptDialog: // AdaptiveDialogs handle new RepromptDialog as it gives access to the dialogContext. await this.repromptDialog(actionContext, actionContext.activeDialog); handled = true; break; } } else { switch (dialogEvent.name) { case AdaptiveEvents.beginDialog: if (!actionContext.state.getValue(TurnPath.activityProcessed)) { const activityReceivedEvent: DialogEvent = { name: AdaptiveEvents.activityReceived, value: activity, bubble: false, }; // Emit trailing ActivityReceived event handled = await this.processEvent(actionContext, activityReceivedEvent, false); } break; case AdaptiveEvents.activityReceived: if (activity.type === ActivityTypes.Message) { // Do we have an empty sequence? if (actionContext.actions.length === 0) { const unknownIntentEvent: DialogEvent = { name: AdaptiveEvents.unknownIntent, bubble: false, }; // Emit trailing UnknownIntent event handled = await this.processEvent(actionContext, unknownIntentEvent, false); } else { handled = false; } } // Has an interruption occurred? // - Setting this value to true causes any running inputs to re-prompt when they're // continued. The developer can clear this flag if they want the input to instead // process the users utterance when its continued. if (handled) { actionContext.state.setValue(TurnPath.interrupted, true); } break; } } return handled; } /** * @protected * Recognizes intent for current activity given the class recognizer set, if set is null no intent will be recognized. * @param actionContext The [ActionContext](xref:botbuilder-dialogs-adaptive.ActionContext) for the current turn of conversation. * @param activity [Activity](xref:botbuilder-schema.Activity) to recognize. * @returns A Promise representing a [RecognizerResult](xref:botbuilder.RecognizerResult). */ protected async onRecognize(actionContext: ActionContext, activity: Activity): Promise<RecognizerResult> { const { text } = activity; const noneIntent: RecognizerResult = { text: text ?? '', intents: { None: { score: 0.0 } }, entities: {}, }; if (this.recognizer) { if (this._recognizerSet.recognizers.length === 0) { this._recognizerSet.recognizers.push(this.recognizer); this._recognizerSet.recognizers.push(new ValueRecognizer()); } const recognized = await this._recognizerSet.recognize(actionContext, activity); const intents = Object.entries(recognized.intents); if (intents.length > 0) { // Score // Gathers all the intents with the highest Score value. const scoreSorted = intents.sort(([, a], [, b]) => b.score - a.score); const [[, firstItemScore]] = scoreSorted; const topIntents = scoreSorted.filter(([, e]) => e.score == firstItemScore.score); // Priority // Gathers the Intent with the highest Priority (0 being the highest). // Note: this functionality is based on the FirstSelector.SelectAsync method. let [topIntent] = topIntents; if (topIntents.length > 1) { let highestPriority = Number.MAX_SAFE_INTEGER; for (const [key, intent] of topIntents) { const [triggerIntent] = this.triggers.filter((x) => x instanceof OnIntent && x.intent == key); const priority = triggerIntent.currentPriority(actionContext); if (priority >= 0 && priority < highestPriority) { topIntent = [key, intent]; highestPriority = priority; } } } const [key, value] = topIntent; recognized.intents = { [key]: value }; } return recognized; } else { return noneIntent; } } private async queueFirstMatch(actionContext: ActionContext): Promise<boolean> { const selection: OnCondition[] = await this.selector.select(actionContext); if (selection.length > 0) { const evt = selection[0]; const properties: { [key: string]: string } = { DialogId: this.id, Expression: evt.getExpression().toString(), Kind: `Microsoft.${evt.constructor.name}`, ConditionId: evt.id, context: TelemetryLoggerConstants.TriggerEvent, }; this.telemetryClient.trackEvent({ name: TelemetryLoggerConstants.GeneratorResultEvent, properties: properties, }); const changes = await evt.execute(actionContext); if (changes && changes.length > 0) { actionContext.queueChanges(changes[0]); return true; } } return false; } //--------------------------------------------------------------------------------------------- // Action Execution //--------------------------------------------------------------------------------------------- /** * @protected * Waits for pending actions to complete and moves on to [OnEndOfActions](xref:botbuilder-dialogs-adaptive.OnEndOfActions). * @param dc The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. * @returns A Promise representation of [DialogTurnResult](xref:botbuilder-dialogs.DialogTurnResult). */ protected async continueActions(dc: DialogContext): Promise<DialogTurnResult> { // Apply any queued up changes const actionContext = this.toActionContext(dc); await actionContext.applyChanges(); // Get a unique instance ID for the current stack entry. // - We need to do this because things like cancellation can cause us to be removed // from the stack and we want to detect this so we can stop processing actions. const instanceId = this.getUniqueInstanceId(actionContext); // Initialize local interruption detection // Any steps containing a dialog stack after the first step indicates the action was interrupted. // We want to force a re-prompt and then end the turn when we encounter an interrupted step. let interrupted = false; // Create context for active action let actionDC = this.createChildContext(actionContext); while (actionDC) { let result: DialogTurnResult<unknown>; if (actionDC.stack.length === 0) { // Start step const nextAction = actionContext.actions[0]; result = await actionDC.beginDialog(nextAction.dialogId, nextAction.options); } else { // Set interrupted flag only if it is undefined if (interrupted && actionDC.state.getValue<boolean>(TurnPath.interrupted) === undefined) { actionDC.state.setValue(TurnPath.interrupted, true); } // Continue step execution result = await actionDC.continueDialog(); } // Is the step waiting for input or were we cancelled? if (result.status === DialogTurnStatus.waiting || this.getUniqueInstanceId(actionContext) !== instanceId) { return result; } // End current step await this.endCurrentAction(actionContext); if (result.status === DialogTurnStatus.completeAndWait) { // Child dialog completed, but wants us to wait for a new activity result.status = DialogTurnStatus.waiting; return result; } let parentChanges = false; let root = actionContext; let parent = actionContext.parent; while (parent) { const ac = parent as ActionContext; if (ac && ac.changes && ac.changes.length > 0) { parentChanges = true; } root = parent as ActionContext; parent = root.parent; } // Execute next step if (parentChanges) { // Recursively call continueDialog() to apply parent changes and continue execution return await root.continueDialog(); } // Apply any locale changes and fetch next action await actionContext.applyChanges(); actionDC = this.createChildContext(actionContext); interrupted = true; } return await this.onEndOfActions(actionContext); } /** * @protected * Provides the ability to set scoped services for the current [DialogContext](xref:botbuilder-dialogs.DialogContext). * @param dialogContext The [DialogContext](xref:botbuilder-dialogs.DialogContext) for the current turn of conversation. */ protected onSetScopedServices(dialogContext: DialogContext): void { if (this.generator) { dialogContext.services.set(languageGeneratorKey, this.generator); } } /** * @protected * Removes the most current action from the given [ActionContext](xref:botbuilder-dialogs-adaptive.ActionContext) if there are any. * @param actionContext The [ActionContext](xref:botbuilder-dialogs-adaptive.ActionContext) for the current turn of conversation. * @returns A Promise representing a boolean indicator for the result. */ protected async endCurrentAction(actionContext: ActionContext): Promise<boolean> { if (actionContext.actions.length > 0) { actionContext.actions.shift(); } return false; } /** * @protected * Awaits for completed actions to finish processing entity assignments and finishes the turn. * @param actionContext The [ActionContext](xref:botbuilder-dialogs-adaptive.ActionContext) for the current turn of conversation. * @returns A Promise representation of [DialogTurnResult](xref:botbuilder-dialogs.DialogTurnResult). */ protected async onEndOfActions(actionContext: ActionContext): Promise<DialogTurnResult> { // Is the current dialog still on the stack? if (actionContext.activeDialog) { // Completed actions so continue processing entity assignments const handled = await this.processQueues(actionContext); if (handled) { // Still processing assignments return await this.continueActions(actionContext); } else if (this.autoEndDialog.getValue(actionContext.state)) { const result = actionContext.state.getValue(this.defaultResultProperty); return await actionContext.endDialog(result); } return Dialog.EndOfTurn; } return { status: DialogTurnStatus.cancelled }; } private getUniqueInstanceId(dc: DialogContext): string { return dc.stack.length > 0 ? `${dc.stack.length}:${dc.activeDialog.id}` : ''; } private toActionContext(dc: DialogContext): ActionContext { const activeDialogState = dc.activeDialog.state; let state: AdaptiveDialogState = activeDialogState[this.adaptiveKey]; if (!state) { state = { actions: [] }; activeDialogState[this.adaptiveKey] = state; } if (!state.actions) { state.actions = []; } const dialogState: DialogState = { dialogStack: dc.stack }; const actionContext = new ActionContext(dc.dialogs, dc, dialogState, state.actions, this.changeTurnKey); actionContext.parent = dc.parent; // use configuration of dc's state if (!actionContext.parent) { actionContext.state.configuration = dc.state.configuration; } return actionContext; } /** * This function goes through the entity assignments and emits events if present. * * @param actionContext The ActionContext. * @returns true if the event was handled. */ private async processQueues(actionContext: ActionContext): Promise<boolean> { let evt: DialogEvent; let handled = false; const assignments = EntityAssignments.read(actionContext); const nextAssignment = assignments.nextAssignment; if (nextAssignment) { nextAssignment.raisedCount ??= 0; if (nextAssignment.raisedCount++ === 0) { // Reset retries when new form event is first issued actionContext.state.deleteValue(DialogPath.retries); } evt = { name: nextAssignment.event, value: nextAssignment.alternative ? nextAssignment.alternatives : nextAssignment, bubble: false, }; if (nextAssignment.event === AdaptiveEvents.assignEntity) { // TODO: (from C#) For now, I'm going to dereference to a one-level array value. There is a bug in the current code in the distinction between // @ which is supposed to unwrap down to non-array and @@ which returns the whole thing. @ in the curent code works by doing [0] which // is not enough. let entity = nextAssignment.value.value; if (!Array.isArray(entity)) { entity = [entity]; } actionContext.state.setValue(`${TurnPath.recognized}.entities.${nextAssignment.value.name}`, entity); assignments.dequeue(actionContext); } actionContext.state.setValue(DialogPath.lastEvent, evt.name); handled = await this.processEvent(actionContext, evt, true); if (!handled) { // If event wasn't handled, remove it. if (nextAssignment && nextAssignment.event !== AdaptiveEvents.assignEntity) { assignments.dequeue(actionContext); } // See if more assignments or end of actions. handled = await this.processQueues(actionContext); } } else { // Emit end of actions evt = { name: AdaptiveEvents.endOfActions, bubble: false, }; actionContext.state.setValue(DialogPath.lastEvent, evt.name); handled = await this.processEvent(actionContext, evt, true); } return handled; } /** * Process entities to identify ambiguity and possible assignment to properties. Broadly the steps are: * Normalize entities to include meta-data * Check to see if an entity is in response to a previous ambiguity event * Assign entities to possible properties * Merge new queues into existing queues of ambiguity events * * @param actionContext The ActionContext. * @param activity The Activity. */ private processEntities(actionContext: ActionContext, activity: Activity): void { if (this.dialogSchema) { const lastEvent = actionContext.state.getValue(DialogPath.lastEvent); if (lastEvent) { actionContext.state.deleteValue(DialogPath.lastEvent); } const assignments = EntityAssignments.read(actionContext); const entities = this.normalizeEntities(actionContext); const utterance = activity.type === ActivityTypes.Message ? activity.text : ''; // Utterance is a special entity that corresponds to the full utterance entities[this.utteranceKey] = [ Object.assign(new EntityInfo(), { priority: Number.MAX_SAFE_INTEGER, coverage: 1, start: 0, end: utterance.length, name: this.utteranceKey, score: 0, type: 'string', value: utterance, text: utterance, }), ]; const recognized = this.assignEntities(actionContext, entities, assignments, lastEvent); const unrecognized = this.splitUtterance(utterance, recognized); // Utterance is a special entity that corresponds to the full utterance actionContext.state.setValue(TurnPath.unrecognizedText, unrecognized); actionContext.state.setValue(TurnPath.recognizedEntities, recognized); assignments.write(actionContext); } } private splitUtterance(utterance: string, recognized: Partial<EntityInfo>[]): string[] { const unrecognized = []; let current = 0; for (let i = 0; i < recognized.length; i++) { const entity = recognized[i]; if (entity.start > current) { unrecognized.push(utterance.substr(current, entity.start - current).trim()); } current = entity.end; } if (current < utterance.length) { unrecognized.push(utterance.substr(current)); } return unrecognized; } private normalizeEntities(actionContext: ActionContext): Record<string, EntityInfo[]> { const entityToInfo = {}; const text = actionContext.state.getValue(`${TurnPath.recognized}.text`); const entities = actionContext.state.getValue(`${TurnPath.recognized}.entities`); if (entities) { const turn = actionContext.state.getValue(DialogPath.eventCounter); const operations: string[] = (this.dialogSchema.schema && this.dialogSchema.schema[this.operationsKey]) ?? []; const properties = Object.keys(this.dialogSchema?.schema['properties'] ?? {}); this.expandEntityObject(entities, null, null, null, operations, properties, turn, text, entityToInfo); } // When there are multiple possible resolutions for the same entity that overlap, pick the // one that covers the most of the utterance. for (const name in entityToInfo) { const infos = entityToInfo[name]; infos.sort((entity1, entity2): number => { let val = 0; if (entity1.start === entity2.start) { if (entity1.end > entity2.end) { val = -1; } else if (entity1.end < entity2.end) { val = +1; } } else if (entity1.start < entity2.start) { val = -1; } else { val = +1; } return val; }); for (let i = 0; i < infos.length; ++i) { const current = infos[i]; for (let j = i + 1; j < infos.length; ) { const alt = infos[j]; if (EntityInfo.covers(current, alt)) { infos.splice(j, 1); } else { ++j; } } } } return entityToInfo; } private expandEntityObject( entities: Record<string, unknown[]>, op: string, property: string, rootInstance: Record<string, unknown>, operations: string[], properties: string[], turn: number, text: string, entityToInfo: Record<string, EntityInfo[]> ): void { Object.keys(entities).forEach((entityName) => { const instances = entities[this.instanceKey][entityName]; this.expandEntities( entityName, entities[entityName], instances, rootInstance, op, property, operations, properties, turn, text, entityToInfo ); }); } // There's a decent amount of type casting in this method and expandEntity() due to the complex // nested structure of `entities`. It can be solved by defining types, but the changes necessary to remove the type casting // are numerous and it can be argued that it adds complexity to do so. private expandEntities( name: string, entities: unknown[], instances: Record<string, unknown>[], rootInstance: Record<string, unknown>, op: string, property: string, operations: string[], properties: string[], turn: number, text: string, entityToInfo: Record<string, EntityInfo[]> ): void { if (!name.startsWith('$')) { // Entities representing schema properties end in "Property" to prevent name collisions with the property itself. const propName = this.stripProperty(name); let entityName: string; let isOp = false; let isProperty = false; if (operations.includes(name)) { op = name; isOp = true; } else if (properties.includes(propName)) { property = propName; isProperty = true; } else { entityName = name; } entities.forEach((entity, index) => { const instance = instances[index]; let root = rootInstance; if (!root) { // Keep the root entity name and position to help with overlap. root = cloneDeep(instance); root.type = `${name}${index}`; } if (entityName) { this.expandEntity( entityName, entity as Record<string, unknown[]>, instance, root, op, property, turn, text, entityToInfo ); } else if (typeof entity === 'object' && entity !== null) { if (isEmpty(entity)) { if (isOp) { // Handle operator with no children. this.expandEntity(op, null, instance, root, op, property, turn, text, entityToInfo); } else if (isProperty) { // Handle property with no children. this.expandEntity(property, null, instance, root, op, property, turn, text, entityToInfo); } } else { this.expandEntityObject( entity as Record<string, unknown[]>, op, property, root, operations, properties, turn, text, entityToInfo ); } } else if (isOp) { // Handle global operator with no children in model. this.expandEntity(op, null, instance, root, op, property, turn, text, entityToInfo); } }); } } private stripProperty(name: string): string { return name.endsWith(this.propertyEnding) ? name.substring(0, name.length - this.propertyEnding.length) : name; } private expandEntity( name: string, value: Record<string, unknown> | null, instance: Record<string, unknown>, rootInstance: Record<string, unknown>, op: string, property: string, turn: number, text: string, entityToInfo: Record<string, EntityInfo[]> ): void { if (instance && rootInstance) { entityToInfo[name] ??= []; const info: EntityInfo = { whenRecognized: turn, name, value, operation: op, property, start: <number>rootInstance.startIndex, end: <number>rootInstance.endIndex, rootEntity: <string>rootInstance.type, text: <string>rootInstance.text ?? '', type: <string>instance.type, score: <number>instance.score ?? 0, priority: 0, coverage: undefined, }; info.coverage = (info.end - info.start) / text.length;