Alan Tsai 的學習筆記


學而不思則罔,思而不學則殆,不思不學則“網貸” 記錄軟體開發的點點滴滴 著重於微軟技術、網頁開發、DevOps、C#, Asp .net Mvc、Azure、AI、Chatbot、Docker、Data Science

[chatbot + AI = 下一代操作模式][10]用IDialog全部重構 - 階段性總結

[chatbot + AI = 下一代操作模式][10]用IDialog全部重構 - 階段性總結.jpg
圖片來源:https://pixabay.com/en/books-spine-colors-pastel-1099067/ 

在上一篇([09]使用IDialog來實現SoC)介紹了怎麽使用IDialog來拆分邏輯,并且一步一步的用取得名字的邏輯拆成為一個NameDialog

在這一篇我們將會把所有的邏輯重構成爲IDialog,并且對於目前學習到的BotBuilder做一個階段性的總結。

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

重構剩下來的邏輯

上篇重構完了取得名字的邏輯之後,我們還剩下幾個部分需要重構:

  1. 查飯店
  2. 取得訂房的費用明細
  3. 訂房

重上一篇的重構方式我們可以看出,整個重構的步奏分爲:

  1. 建立一個Dialog
  2. 把邏輯從root放到dialog裡面
  3. 修正root為呼叫dialog來觸發
  4. 測試

接下來我們就快速看一下這幾個邏輯拆分的程式部分。

查飯店

首先我們建立一個IDialog叫做SearchHotelDialog。這個SearchHotelDialog回回傳一個HeroCard

[Serializable]
public class SearchHotelDialog : IDialog<HeroCard>
{
	public Task StartAsync(IDialogContext context)
	{
		throw new NotImplementedException();
	}
}

在來我們把HeroCard的產生放在Dialog裡面:

public Task StartAsync(IDialogContext context)
{
	// 返回飯店的圖片以及可以打開官網的按鈕

	// 建立一個HeroCard
	var herocard = new HeroCard()
	{
		Title = "xxx飯店",
		Text = "5星級高級大飯店",
		Images = new List<CardImage>()
				{
					   new CardImage("https://cdn.pixabay.com/photo/2016/02/10/13/32/hotel-1191709_1280.jpg")
				},
		Buttons = new List<CardAction>()
				{
					new CardAction("openUrl", "官網", value: "http://www.google.com")
				}
	};

	context.Done(herocard);

	return Task.CompletedTask;
}

這邊我們沒有真的搜索資料庫,但是可以想象一下,這邊可以詢問使用者條件然後把結果回傳,甚至回傳多筆讓使用者選擇那筆。不過,這個部分就給各位去發揮啦。

最後我們要在RootDialog做一些調整:

...
if (activity.Text == "查飯店")
{
	context.Call(new SearchHotelDialog(), async (ctx, r) =>
	{
		var returnMessage = activity.CreateReply();
		var heroCardResult = await r;
		returnMessage.Attachments = new List<Attachment>(){ heroCardResult.ToAttachment() };

		await context.PostAsync(returnMessage);
		context.Wait(MessageReceivedAsync);
	});
}
...

最後我們測試,發現結果和之前一樣。

取得訂房的費用明細

再來我們重構取得費用明細,先建立一個GetReciptDialog,這個Dialog會回傳一個Attachment

[Serializable]
public class GetReceiptDialog : IDialog<Attachment>
{
	public Task StartAsync(IDialogContext context)
	{
		throw new NotImplementedException();
	}
}

再來把邏輯搬進去:

public Task StartAsync(IDialogContext context)
{
	var receiptCard = new ReceiptCard()
			{
				Title = "訂房費用",
				Total = "NT$ 120",
				Tax = "NT$ 20",
				Items = new List<ReceiptItem>()
				   {
					   new ReceiptItem()
					   {
							Title = "1大房",
							Price = "90",
							Quantity = "1",
							Image = new CardImage("https://cdn.pixabay.com/photo/2014/08/11/21/40/wall-416062__180.jpg")
					   },
					   new ReceiptItem()
					   {
						   Title = "飲料",
						   Price = "10",
						   Quantity = "1",
						   Image = new CardImage("https://cdn.pixabay.com/photo/2014/09/26/19/51/coca-cola-462776_1280.jpg")
					   }
				   }
			};

	context.Done(receiptCard.ToAttachment());

	return Task.CompletedTask;
}

這次我回傳不是ReceiptItem而是一個Attachment,因爲Attachment是更加generic的形態,我們剛剛那個查飯店其實應該也要調整,這個我們等一下處理。

接下來我們調整RootDialog,這邊我們把關鍵字改成明細比較清楚:

...
else if(activity.Text == "明細")
{
	context.Call(new GetReceiptDialog(), async (ctx, r) =>
	{
		var returnMessage = activity.CreateReply();
		var attachmentResult = await r;
		returnMessage.Attachments = new List<Attachment>() { attachmentResult };

		await context.PostAsync(returnMessage);
		context.Wait(MessageReceivedAsync);
	});
}
...

接下來測試一下,發現結果一樣。

查飯店v2調整

還記得我們之前爲了顯示AdaptiveCard有做了一個v2版本的查飯店。

我們把他和SearchHotelDialog整合,改成一次回傳兩家飯店,一個用HeroCard,一個用AdaptiveCard

首先,我們調整SearchHotelDialog的回傳内容,從HeroCard改成List <Attachment>

public class SearchHotelDialog : IDialog<List<Attachment>>

再來調整dialog裡面的邏輯:

public Task StartAsync(IDialogContext context)
{
	// 返回飯店的圖片以及可以打開官網的按鈕

	// 建立一個HeroCard
	HeroCard herocard = GetHeroCard();

	AdaptiveCard adCard = GetAdaptiveCard();

	context.Done(new List<Attachment>() { herocard.ToAttachment(),
			new Attachment()
			{
				Content = adCard,
				ContentType = AdaptiveCard.ContentType
			}
	 });

	return Task.CompletedTask;
}

最後調整RootDialog,把查飯店v2刪掉,然後調整查飯店取得的回傳形態:

if (activity.Text == "查飯店")
{
	context.Call(new SearchHotelDialog(), async (ctx, r) =>
	{
		var returnMessage = activity.CreateReply();
		var attachments = await r;
		returnMessage.Attachments = attachments;

		await context.PostAsync(returnMessage);
		context.Wait(MessageReceivedAsync);
	});
}

最後,我們可以看到,輸入了查飯店,可以看到兩個版本的飯店:

Bot Framework Emulator_2018-07-14_23-00-18.png
最後輸出結果

訂房調整

我們的訂房使用的是FormFlow來產生,這一整個邏輯也可以重構到dialog裡面,做法和其他一樣,先建立一個ReserveRoomDialog

[Serializable]
public class ReserveRoomDialog : IDialog<RoomReservation>
{
	public Task StartAsync(IDialogContext context)
	{
		throw new NotImplementedException();
	}
}

再來,我們把邏輯放到了Dialog裡面:

public Task StartAsync(IDialogContext context)
{
	var reserveRoomForm =
				FormDialog.FromForm(RoomReservation.BuildForm,
					FormOptions.PromptInStart);

	context.Call(reserveRoomForm, AfterReserveRoomAsync);

	return Task.CompletedTask;
}

private async Task AfterReserveRoomAsync(IDialogContext context
   , IAwaitable<RoomReservation> result)
{
	RoomReservation reservation = null;

	try
	{
		reservation = await result;

		//await context.PostAsync($"得到的結果:{Environment.NewLine} {JsonConvert.SerializeObject(reservation)}");
	}
	catch (FormCanceledException<RoomReservation> ex)
	{
		string reply;

		if (ex.InnerException == null)
		{
			reply = $"您在 {ex.Last} 的時候退出了 -- 如果有遇到任何問題請告訴我們";
		}
		else
		{
			reply = "機器人暫時罷工了,請稍後嘗試";
		}

		await context.PostAsync(reply);
	}
	finally
	{
		context.Done(reservation);
	}
}
由於回傳的是RoomReservation,因此當使用者退出的時候,直接在這邊輸出内容了。但是,既然回傳的是一個物件,那麽更好的做法應該是一個包含RoomReservation的物件,裡面有資訊可以記錄 像是退出錯誤的訊息。這個就留給大家嘗試。

最後,調整Rootdialog:

...
else if(activity.Text == "訂房")
{
	context.Call(new ReserveRoomDialog(), ReserverRoomAfterAsync);
}

private async Task ReserverRoomAfterAsync(IDialogContext context,
   IAwaitable<RoomReservation> result)
{
	var roomReserved = await result;

	if (roomReserved != null)
	{
		await context.PostAsync($"您的訂單資訊:{Environment.NewLine}" +
			$"{JsonConvert.SerializeObject(roomReserved, Formatting.Indented)}");
	}
	else
	{
		await context.PostAsync($"訂單取得失敗");
	}

	context.Wait(MessageReceivedAsync);
}
這邊把關鍵字也調整變成訂房

内建的Dialog - 範例 Prompt

透過這幾次的重構,相信對於Dialog的建立有了概念,并且這些Dialog是可以簡單重複使用。

這邊舉個簡單例子,内建有個Dialog能夠讓我們方便取得使用者的回復,舉例來説,訂房最後應該有個確認輸入,這個時候我們可以使用内建的Dialog,Prompt

private async Task ReserverRoomAfterAsync(IDialogContext context,
   IAwaitable<RoomReservation> result)
{
	var roomReserved = await result;

	if (roomReserved != null)
	{
		await context.PostAsync($"您的訂單資訊:{Environment.NewLine}" +
			$"{JsonConvert.SerializeObject(roomReserved, Formatting.Indented)}");

		PromptDialog.Confirm(context, ConfirmReservation, "請確認訂房資訊");
	}
	else
	{
		await context.PostAsync($"訂單取得失敗");

		context.Wait(MessageReceivedAsync);
	}
}

private async Task ConfirmReservation(IDialogContext context, IAwaitable<bool> result)
{
	var confirmResult = await result;

	if(confirmResult)
	{
		await context.PostAsync($"訂單完成。訂單號:{DateTime.Now.Ticks}");
	}
	else
	{
		await context.PostAsync("訂房取消");
	}

	context.Wait(MessageReceivedAsync);
}

最後我們看到輸入完表單了之後,會有個確認的訊息:

Bot Framework Emulator_2018-07-14_23-54-36.png
最後確認畫面

由這個範例看出,我們可以透過重構IDialog讓我們邏輯能夠重複使用,這樣就不用一直寫這些utility功能。

結語

到目前爲止,我們已經把整個BotBuilder的主要component瞭解的7788了。我們從訊息格式瞭解起,知道用Activity作爲統一的格式,然後透過Rich Card的輸出内容讓我們可以讓輸出變得更加漂亮。

再來我們看了FormFlow,用它來快速建立表單輸入的方式。

最後介紹了用Dialog來拆分邏輯,并且介紹了其中一個内建的Dialog,PromptDialog

BotBuilder還有一些細節,例如透過Scoreable來處理一些特殊關鍵字,不過這個以後有機會在介紹。

對於開發一個Chatbot目前的知識量已經足夠了,并且我們透過這些知識做出了一個訂房的chatbot,接下來我們就可以考慮把他散佈出去讓大家來給我們一些feedback。

因此,在下一篇(),我們來看看如何部署我們的chatbot。


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