Alan Tsai 的學習筆記


學而不思則罔,思而不學則殆,不思不學則“網貸” 為現任微軟最有價值專家 (MVP)、微軟認證講師 (MCT) 、Blogger、Youtuber:記錄軟體開發的點點滴滴 著重於微軟技術、C#、ASP .NET、Azure、DevOps、Docker、AI、Chatbot、Data Science

[Bot Framework V4][10]在Dialog裡面做Branching以及Looping把不同功能更加模組化

[Bot Framework V4][10]在Dialog裡面做Branching以及Looping把不同目的更加模組化.jpg
圖片來源:https://pixabay.com/en/books-spine-colors-pastel-1099067/ 

在上一篇([09]使用waterfall建立表單式填寫)介紹了使用watfall的方式達到建立一個表單式搜集的chatbot。

裡面爲了簡化把取得姓名的部分暫時拿掉了,但是在實務上不同邏輯的dialog可能會存在,那怎麽辦呢?

這篇將介紹透過Dialog來做Branching以及Looping。

這篇的程式碼github頁面是alantsai-samples/mhat-hotelbotv4:blog/chapter-10

什麽是Branching和Looping

Branching就和名稱一樣的意思,分叉出去。

舉例來説,假設我的waterfall啓動的時候,因爲收到了什麽訊息這個時候啓動另外一個waterfall, 這個時候除非另外一個(剛啓動那個)waterfall結束,要不然不會繼續往原本的繼續執行下去。

這個就是所謂的Branching,先分叉出去,然後執行完了在分叉回來。

Branching是透過呼叫BeginDialogAsync達到。

Looping和字面上面的意思也一樣,當waterfall執行到最後一步的時候,不結束,重新又new一個同樣的waterfall,這樣就延續執行下去。

換而言之,就達到了一直loop。

Looping是透過呼叫ReplaceDialogAsync來達到。

加入Branching和Looping的概念到目前的範例

對到目前的範例來説:

  1. 需要有branching分支出去執行取得姓名以及取得訂房這兩個flow
  2. 需要有loop,這樣整個waterfall永遠不會結束

因此整個流程概念如下:

整個流程的概念

上圖有幾個重要的部分:

藍色的框框代表3個大flow

有3個大藍色的框框:

  1. root - 這個是整個流程的起始點。root裡面有個echo - 當另外兩個branching條件不符合的時候,echo會觸發。
  2. askNameWaterfall - 當使用者沒有輸入過姓名的時候,將透過branching的方式觸發這段的waterfall
  3. bookRoom - 當使用者輸入訂房的時候,將透過branching的方式觸發

橘紅色的綫
代表是branching的部分,從root這個waterfall切換到另外兩個branching。
這邊echo有點特別,因爲他不屬於branching因此屬於root的一部分
綠色的綫
代表是looping的部分。當其他watefall結束的時候,都會回到root waterfall。

修改現行的程式碼

上面有了概念之後,來看看如何調整現行的程式碼來達到使用branching以及looping。

整個修改會分爲幾個部分:

  1. 建立一個HotelDialogSet
  2. 完成詢問姓名的waterfall
  3. 完成訂房的waterfall
  4. 完成root waterfall
  5. 整合到bot呼叫HotelDialogSet

建立一個HotelDialogSet

首先建立出一個class叫做HotelDialogSet,然後讓這個class繼承DialogSet,透過這個方式讓邏輯整合到這個class就好。

由於之後會使用到Accessor來取得儲存的值,因此會用建構子傳入來,最後整個class:

public class HotelDialogSet : DialogSet
{
	private EchoBotAccessors _accessors;

	public HotelDialogSet(IStatePropertyAccessor<DialogState> dialogState,
		EchoBotAccessors accessors) 
		: base(dialogState)
	{
		_accessors = accessors;
	}
}

完成詢問姓名的waterfall

首先來完成詢問姓名的waterfall,整個的邏輯和之前篇幅看到的一樣,定義出一個WaterfallDialog,用來取得使用者姓名:

public class HotelDialogSet : DialogSet
{
	....
	
	public string askNameWaterfall { get; } = "askNameWaterfall";

	public HotelDialogSet
		(IStatePropertyAccessor<DialogState> dialogState, EchoBotAccessors accessors) 
		: base(dialogState)
	{
		...
		
		var askNameDialogSet = new WaterfallStep[]
		{
			StartPromptName,
			ProcessPromptName,
		};

		Add(new WaterfallDialog(askNameWaterfall, askNameDialogSet));

		Add(new TextPrompt("textPrompt"));
	}

	private async Task<DialogTurnResult> ProcessPromptName
		(WaterfallStepContext stepContext, CancellationToken cancellationToken)
	{
		var userInfo = await _accessors.UserInfo.GetAsync
			(stepContext.Context, () => new Model.UserInfo());

		userInfo.Name = stepContext.Result.ToString();

		await _accessors.UserInfo.SetAsync(stepContext.Context, userInfo);
		await _accessors.UserState.SaveChangesAsync(stepContext.Context);

		await stepContext.Context.SendActivityAsync($"{userInfo.Name} 您好");

		return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
	}

	private async Task<DialogTurnResult> StartPromptName
		(WaterfallStepContext stepContext, CancellationToken cancellationToken)
	{
		return await stepContext.PromptAsync("textPrompt", new PromptOptions()
		{
			Prompt = MessageFactory.Text("請問尊姓大名?"),
		}, 
		cancellationToken);
	}
}

上面程式碼應該不太需要介紹,定義了了一個Waterfall,裡面有兩個step用來取得姓名以及儲存在Accessor。

完成訂房的waterfall

看過了上面詢問名字的部分,相信對於訂房的做法也就很清楚了 - 一樣是建立一個waterfall,裡面定義出完成訂房需要的step。

首先是在建構子的時候建立出waterfall的step:

public HotelDialogSet
	(IStatePropertyAccessor<DialogState> dialogState, EchoBotAccessors accessors) 
	: base(dialogState)
{
	...
	
	var waterfallSteps = new WaterfallStep[]
	{
		GetStartStayDateAsync,
		GetStayDayAsync,
		GetNumberOfOccupantAsync,
		GetBedSizeAsync,
		GetConfirmAsync,
		GetSummaryAsync,
	};

	Add(new WaterfallDialog("bookRoom", waterfallSteps));
	Add(new DateTimePrompt("dateTime"));
	Add(new NumberPrompt<int>("number"));
	Add(new ChoicePrompt("choice"));
	Add(new ConfirmPrompt("confirm"));
}

再來就是看看每一個step的實際動作:

...

#region bookRoom
private async Task<DialogTurnResult> GetSummaryAsync
  (WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
	if ((bool)stepContext.Result)
	{
		await stepContext.Context.SendActivityAsync
			($"訂單下定完成,訂單號:{DateTime.Now.Ticks}");
	}
	else
	{
		await stepContext.Context.SendActivityAsync("已經取消訂單");
	}

	return await stepContext.EndDialogAsync(cancellationToken: cancellationToken);
}

private async Task<DialogTurnResult> GetConfirmAsync
	(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
	var roomReservation = (await GetCounterState(stepContext.Context))
		.RoomReservation;

	roomReservation.BedSize = ((FoundChoice)stepContext.Result).Value;

	return await stepContext.PromptAsync("confirm", new PromptOptions()
	{
		Prompt = MessageFactory.Text($"請確認您的訂房條件:{Environment.NewLine}" +
		$"{roomReservation}")
	});
}

private async Task<DialogTurnResult> GetBedSizeAsync
	(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
	(await GetCounterState(stepContext.Context))
		.RoomReservation.NumberOfPepole = (int)stepContext.Result;

	var choices = new List<Choice>()
	{
		new Choice("單人床"),
		new Choice("雙人床"),
	};

	return await stepContext.PromptAsync("choice",
		new PromptOptions()
		{
			Prompt = MessageFactory.Text("請選擇床型"),
			Choices = choices,
		},
		cancellationToken);
}

private async Task<DialogTurnResult> GetNumberOfOccupantAsync
	(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
	(await GetCounterState(stepContext.Context))
		.RoomReservation.NumberOfNightToStay = (int)stepContext.Result - 1;

	return await stepContext.PromptAsync("number",
		new PromptOptions()
		{
			Prompt = MessageFactory.Text("幾人入住"),
		},
		cancellationToken);
}

private async Task<DialogTurnResult> GetStayDayAsync
	(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
	(await GetCounterState(stepContext.Context))
		.RoomReservation.StartDate =
		DateTime.Parse(((List<DateTimeResolution>)stepContext.Result).First().Value);

	return await stepContext.PromptAsync("number", new PromptOptions()
	{
		Prompt = MessageFactory.Text("請輸入要住幾天"),
	},
	cancellationToken);
}

private async Task<DialogTurnResult> GetStartStayDateAsync
	(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
	return await stepContext.PromptAsync("dateTime",
		new PromptOptions()
		{
			Prompt = MessageFactory.Text("請輸入入住日期"),
		},
		cancellationToken);
}
#endregion

完成root waterfall

剩下最後一個waterfall了,也就是所有的起點,root的waterfall。

一樣就是先在建構子定義出這個waterfall的step:

...
var rootSteps = new WaterfallStep[]
{
	StartRootAsync,
	ProcessRootAsync,
	LoopRootAsync,
};

Add(new WaterfallDialog("root", rootSteps));

再來看看實際step裡面執行的内容:

private async Task<DialogTurnResult> LoopRootAsync
	(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
	return await stepContext.ReplaceDialogAsync("root", null, cancellationToken);
}

private async Task<DialogTurnResult> ProcessRootAsync(WaterfallStepContext stepContext,
 CancellationToken cancellationToken)
{
	var userInfo = await _accessors.UserInfo.GetAsync(
		stepContext.Context, () => new Model.UserInfo());

	if (string.IsNullOrEmpty(userInfo.Name))
	{
		return await stepContext.BeginDialogAsync(
			askNameWaterfall, null, cancellationToken);
	}
	else if (stepContext.Result.ToString() == "訂房")
	{
		return await stepContext.BeginDialogAsync(
			"bookRoom", null, cancellationToken);
	}
	else
	{
		CounterState state = await GetCounterState(stepContext.Context);

		state.TurnCount++;

		// Set the property using the accessor.
		await _accessors.CounterState.SetAsync(stepContext.Context, state);

		// Save the new turn count into the conversation state.

		// Echo back to the user whatever they typed.
		var responseMessage = $"Name: {userInfo.Name} Turn {state.TurnCount}: You sent '{stepContext.Result}'\n";
		await stepContext.Context.SendActivityAsync(responseMessage);

		return await stepContext.ContinueDialogAsync(cancellationToken);
	}
}

private async Task<DialogTurnResult> StartRootAsync(WaterfallStepContext stepContext,
 CancellationToken cancellationToken)
{
	return await stepContext.PromptAsync("textPrompt", new PromptOptions()
	{
		Prompt = MessageFactory.Text("您好,能夠幫到您什麽?"),
	},
	cancellationToken);
}

這邊最重要的就是ProcessRootAsync以及LoopRootAsync

ProcessRootAsync裡面,透過呼叫BeginDialogAsync來做到branching - 依照輸入内容不同branch到不同的waterfall。

然後在LoopRootAsunc裡面呼叫ReplaceDialogAsync來做到looping

整合到bot呼叫HotelDialogSet

最後切換到EchoWithCounterBot來設定把整個流程啓動起來。

首先把HotelDialogSet在建構子的時候建立出來:

public class EchoWithCounterBot : IBot
{
	private readonly HotelDialogSet _dialogs;
	
	public EchoWithCounterBot(EchoBotAccessors accessors,
		ILoggerFactory loggerFactory)
	{
		...

		_dialogs = new HotelDialogSet(_accessors.DialogState, accessors);
	}
}

最後要把整個啓動起來:

public async Task OnTurnAsync(ITurnContext turnContext, 
	CancellationToken cancellationToken = default(CancellationToken))
{
	if (turnContext.Activity.Type == ActivityTypes.Message)
	{
		var dc = await _dialogs.CreateContextAsync(
			turnContext, cancellationToken);

		await dc.ContinueDialogAsync(cancellationToken);

		if (!turnContext.Responded)
		{
			await dc.BeginDialogAsync
				("root", null, cancellationToken);
		}
	}
	else
	{
		await turnContext.SendActivityAsync
			($"{turnContext.Activity.Type} event detected");
	}

	await _accessors.ConversationState.SaveChangesAsync(turnContext);
}
這邊忽略了要從EchoWithCounterBot裡面刪除原本code的部分處理 - 如果在處理上有遇到問題,歡迎參考範例程式碼。

測試結果

首先是看取得姓名的部分:

botframework-emulator_2018-11-13_22-49-49.png
沒有詢問過姓名任何輸入都會觸發

再來使用關鍵字訂房

botframework-emulator_2018-11-13_22-51-11.png
觸發訂房的waterfall

最後如果輸入其他任何内容,都變成echo模式:

botframework-emulator_2018-11-13_22-51-21.png
測試其他輸入内容

結語

這篇透過建立出一個自己的DialogSet并且透過branching以及looping的方式讓整個組合運作起來。

透過邏輯整合到自己的DialogSet,在呼叫端(EchoWithCounterBot)變得非常的乾净,并且邏輯也分別出去了。

可是還是產生了別的問題,現在所有邏輯都卡在了DialogSet裡面,尤其是2個waterfall明明是不同的東西難道不能夠抽出去嗎?可不可以抽到一個獨立module然後需要的時候整合使用呢?

這就是composite dialogs的作用,下一篇再來介紹。


如果文章對您有幫助,就請我喝杯飲料吧
街口支付QR Code
街口支付QR Code
台灣 Pay QR Code
台灣 Pay QR Code
Line Pay 一卡通 QR Code
Line Pay 一卡通 QR Code
街口支付QR Code
支付寶QR Code
街口支付QR Code
微信支付QR Code
comments powered by Disqus