Alan Tsai 的學習筆記


學而不思則罔,思而不學則殆,不思不學則“網貸” 記錄軟體開發的點點滴滴 著重於微軟技術、網頁開發、DevOps、C#, Asp .net Mvc、Azure、AI、Chatbot、Docker、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
街口支付QR Code
支付寶QR Code
街口支付QR Code
微信支付QR Code
comments powered by Disqus