2020年1月22日 星期三

跨年不寂寞,讓 Google Assistant 陪你猜數字

有了我們上次的 webhook 之後,我們可以真的來挑戰一些更複雜的助理智障功能,這次就來做跟你猜數字的助理,這樣就算跨年沒有朋友,還是有助理跟你玩有趣的猜數字遊戲,從螢幕感受到滿滿的溫暖 (欸。

其實做猜數字要做的事,在上篇的 webhook 中都差不多了。
我們可以簡單畫一下流程圖,從開頭的 default welcome intent 開始:
畫個簡單的流程圖是很重要的,特別是當設計的助理功能複雜到一定程度的時候,直接徒手下去硬幹很容易迷失在 intent 海洋中,特別是 dialogflow 的介面設計,在個別 Intent 中只能看到這個 Intent 會處理什麼輸入,給出什麼輸出,列出全體 Intent 的介面又看不出各 Intent 之間的關係,一下子就會迷失做亂掉,有了流程圖就能在編輯各 Intent 的時候,照著流程圖一一設定好。
Webhook 也是,要在單一的 webhook 裡面處理所有的 Intent 該怎麼回應,當 Intent 數量一多的時候就會亂掉,所以都要事先做好規劃。

簡單說一下上面的流程圖,default welcome intent 進來會產生一個數字,之後使用者輸入數字(猜數字),如果數字不對,會顯示更新的區間;對了就會顯示一句稱讚的話然後離開對話,讓我們開始實作:
  • 設定 Default Welcome Intent
這裡我們要回應一句請使用者猜數字的話,這句話簡單可以讓 dialogflow 自己回應就好,不過我們還是要打開 webhook,讓後端的伺服器產生一個亂數出來。
在 webhook 的部分,如果偵測到 Intent 是 Default Welcome Intent,就用 random 產生一個亂數出來;另外要把這個 session id 跟亂數寫到資料庫裡,這個 session id 是固定的,同樣的 session id 就會對應到同一組對話。
  • 新增一個 Intent GuessNumber
這個 Intent 裡面可以設定一些例句,像是:
I guess it is 11.
Let me think. 25.
38.
Maybe 0.
在 parameter 的地方把這個數字抽取出來,設定型別是 sys.number-integer,變數名number,這個 Intent 也要打開 webhook 讓伺服器處理使用者猜數字的行為。
  • 新增一個 Intent GuessEnd
這個 Intent 不會由使用者的輸入進入,而是我們在 GuessNumber 的 webhook 設定 assistant 進入的狀態,在這裡要新增一個 Event,我叫它 User_number_match,在回應的部分設定一些恭禧使用者的話,然後設定這個 Intent 結束對話 End of Conversation。
之所以要新增這個 event,是要讓 webhook 有能力讓 dialogflow 判定要進到這個 Intent,一般 Intent 的判定都是透過使用者的輸入來決定,但在猜數字裡面使用者輸入數字判定的 Intent 一定是 GuessNumber 不會是 GuessEnd,那對話就無法結束了。因此我們自定義這個 User_number_match 事件,只要 webhook 發出這個事件 dialogflow 就會判定為 GuessEnd Intent 了。

再來就可以寫 code 了,如上篇文所述,可以從送來的 json 中,從 queryResult -> intent -> displayName 拿到 Intent 的名字,用這個名字就能分派到不同的函式來處理;另外一個就是 json 的 session 可以拿到 session id。
session = data.get("session")
action_name = data.get("queryResult").get("intent").get("displayName")
我的處理函式就是對三個出現的 Intent 去處理:
Default Welcome Intent 產生亂數並寫入資料庫,這裡我是偷懶用 python 的 pickledb,雖然這樣推到 gae 上面可能會沒辦法用,但光為了這種小應用就要動用 gae 的 datastore 實在是有點大砲打小鳥,用 pickledb 展示一下概念就好了:
target = random.randint(low, high)
db.set(session, (low, target, high))
text = "I have a number between {} and {}. Can you guess it?".format(low, high)
reply = { "fulfillmentText": text }
return jsonify(reply)

GuessNumber 的 webhook 會從資料庫裡面把存起來的數字拿出來,並從 queryResult/parameters/number 拿到使用者輸入的數字,雖然我的型別選擇 number-integer 了,dialogflow 還是塞了個 number float 給我,只能用 int 轉成 integer。
後面就可以拿 guessnum 去跟 target 做比較,如果一樣的話就不會回覆 fulfillment 而是發送之前設定好的事件 User_number_match ,讓 dialogflow 進到 GuessEnd 並結束對話;不一樣的話就縮小可以猜的區間,設定回覆訊息給使用者。
minnum, target, maxnum = db.get(session)
guessnum = int(data.get("queryResult").get("parameters").get("number"))
if guessnum == target:
  event = "User_number_match"
  reply = { "followup_event_input" : { "name" : "User_number_match" } }
  return jsonify(reply)
else:
  # update minnum, maxnum here
  db.set(session, (minnum, target, maxnum))
  text = "A number between {} and {}. Keep guess.".format(minnum, maxnum)
  reply = { "fulfillmentText": text }
  return jsonify(reply)
  
GuessEnd Intent 的 webhook 就很簡單,把 session id 對應的條目庫裡面刪掉就可以了。

讓我們來測試一下:


2020年1月11日 星期六

連接 Google Assistant Webhook

上一篇可以看到,我們的 action 可以從我們說的話裡面萃取出關鍵字詞,一般簡單的回應可以在 Intent 裡面剖析、回應,但 dialogflow 也僅止於判斷語意跟萃取關鍵字,如果使用者要使用外部服務,像是訂車票之類,一定要連接到外部訂票網站,這個時候就需要借助 webhook 的力量了。
dialogflow 可以讓一個 Intent 的 fulfillment,也就是完成回應,送到另一個 server 的 webhook 來處理,由伺服器回應使用者的需求,同時間伺服器也能去呼叫其他的 API 服務,完成 Google Assistant 幫助使用者完成某件事情;這個做法的優先權比較高,我試過用了 webhook ,它的回應會蓋過 Intent 裡面設定的回應,完整的介紹可以參考 Google 的文件

如果有看 codelab 的課程,裡面使用的回應是用 dialogflow 內建的 server 或是連接到 firebase 上面的 server,兩個都是用 nodejs 實作,這是我們第一個要解的問題:我不想寫 nodejs 看到 nodejs 就會傷風感冒頭痛發燒上吐下瀉四肢無力,所以我們不能用 nodejs。
幸好隨手拜 google 大神,就找到有人用 python 架 server,之前在 MOPCON 講 COSCUP chatbot 的講者大大也是用 golang 架 server,所以要擺脫 nodejs 一定是沒問題。

我們這篇的目標是做一個把 python server 給架起來,然後把歡迎訊息改成用 webhook來回應,都是 google 的服務,伺服器可以用同專案開 gae 放在上面,但測試時用 ngrok 本地測試會比較方便。
首先是先設定 webhook,在 dialogflow 裡面把 Default Welcome Intent 最下面 fulfillment 的 Enable webhook call for this intent 打開:

在 fulfillment 裡面打開 webhook,在 URL 裡面填上 webhook 的位址,這部分等等寫好服務之後再來填:

下面開始寫我們首先開一個新的 python 專案,建立 pip requirements.txt:
# requirements.txt
Flask==1.1.1

使用 pip 跟 virtualenv 建立環境:
$ python -m venv env
$ source env/bin/activate
$ pip install -r requirements.txt

接著建立 flask 實作的 webhook:
from flask import Flask, request, jsonify, make_response
import json

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
  data = request.get_json(silent=True, force=True)
  print("Request:{}".format(json.dumps(data, indent=2)))
  action_name = data.get("queryResult").get("intent").get("displayName")
  if action_name == "Default Welcome Intent":
    text = "Welcome to my google assistant"
    reply = { "fulfillmentText": text }
    return jsonify(reply)
下面是一個 Default Welcome Intent 的 webhook request,要看這個內容可以使用 dialogflow 右手邊的 Try it now 搭配 diagnostic info,可以確認 dialogflow 在判斷 Intent 有沒有錯誤,還有 fulfillment request ,也就是送到 webhook 的內容。

下面節錄我 welcome 訊息的 request:
  • session:一個對話的 id,每一輪的話都會是同一個 id,作為對話的識別:
  • queryResult queryText:對話的內容
  • queryResult intent displayName:目前 dialogflow 判定使用者的意圖
dialogflow 接受的回應內容是 json 格式,可以填充的內容請見參考文件,最簡單的一個回應就是設定 key 為 fulfillmentText 的內容,這個內容就會是 Google Assistant 要顯示給使用者的回應。

最後我們使用 ngrok 來進行測試,ngrok 是一個網路服務,幫你把連接到 ngrok 的連線重導向到 localhost。在使用 ngrok 之前,測試架在雲端的網路服務流程會像這樣:
寫程式;在 local 進行有限的測試;上傳到雲端(等等等);跑了之後發現 server 炸掉;去雲端上面撈 log 檔(等等等);改完之後所有步驟重複一次。
用了 ngrok 之後,程式在本地、log 檔也在本地,上述耗時又麻煩的上傳雲端、撈 log 檔都省下來,真的是瞬間人生變成彩色的。

使用 ngrok 也非常簡單,安裝好 ngrok 之後照著網頁的指示先註冊金鑰:
$ ngrok authtoken <token>
$ ngrok http 8080
Forwarding https://wwwwww.ngrok.io -> http://localhost:8080

也就是 wwwwww.ngrok.io 已經被映射到我們的 localhost:8080 由 flask 執行的伺服器,因此我們可以在 webhook 的地方填入 wwwwww.ngrok.io/webhook。

我們來測試一下:

看到我們的 Assistant 回應了我們在 webhook 設定的回應。

2020年1月4日 星期六

跨年好寂寞?使用 Google Assistant 跟你對話

故事是這樣子的,2019-2020 的跨年,小弟邊緣人沒地方去,後來就自己回家當紅白難民,然後還沒聽到 Lisa 唱紅蓮華QQQQ,不過幸好宅宅有宅宅的做法,沒聽到紅蓮華我們可以看 Youtube 別人上傳的影片,沒有朋友跨年我們可以自幹朋友,也就是我們今天的主角:Google Assistant。
如果平常有用 Google Pixel 手機,或是家裡有 Google Home 裝置的,應該就會知道它上面附的 Google Assistant Christina,雖然說我覺得還是滿沒用的啦,我自己只會拿它來查天氣,有 Google Home 的同學只用它來開關燈,但反正,都有這麼好的工具了為什麼不來好好玩一下?就趁著跨年的假日做點不一樣的事來玩。

下面是一個基礎的流程,跟 codelabs 的課程(搜尋 google assistant 有三級課程可以上)一樣,做一個會回應你的小程序,我們想做到的就是:它會問你最喜歡的顏色,然後會重複你的話:你最喜歡的顏色是XXX。

首先來到 google action 的頁面(雖然是 Google Assistant 卻叫 Google Action 呵呵)先建立一個新的 project,類型選擇對話 Conversational,語言我是建議先選英文,之後應該會試著做做看中文的助手,但英文比較萬無一失。
建立之後要設定一個發語詞,平常在手機上呼叫 Google Assistant 是用 OK google,另外也可以用 talk to my "發語詞" 來呼叫你寫的程式,或者是打開某個 App,這裡我們沒決定名字就叫它 TestApp 就好。


下一步要產生一個 Action,選擇 Custom Intent 再點 Build ,這會連結你的 action 到 dialogflow 建立一個新的 Agent,可以想像一個 Agent 就是回話的機器人,這裡一樣語言建議選擇英文,時后就選擇 +8 時區(不知道為什麼只有香港可選)。

這個背後流程是這樣子的,你跟 Google Assistant 講話之後,Google Assistant 會把這段話送到 Google Action,那 Google Action 又要怎麼理解這段話?就是靠 dialogflow 服務,算是一個簡化版本的自然語言理解框架,可以理解說話的意圖,解析出關鍵字送出回應,而中間這些關鍵字跟回應是可以由設計者設定的;其他家類似的服務像是 Amazon Lex、IBM Watson 等。
dialogflow 由許多的 Intent(意圖)所構成,dialogflow 會從 google assistant 來的輸入辨識出現在要選擇哪個意圖,然後照著意圖的設定去回應;可以把google assistant 想像成一個狀態機,意圖想成一個狀態,照使用者的輸入進到不同狀態,依狀態決定輸出內容,以及下一個可能的狀態。

預設一定有的意圖就是 Default Welcome Intent,也就是一啟動 google assistant 時的的狀態,我們在它的 Response 裡面加上回應:"What is your favorite color?",這樣程式一開就會把問題丟給使用者。
這句話非常的重要,設計 chatbot 最重要的就是把使用者限制在一個小框框裡面,讓使用者針對只有有限選項或是只能答 yes/no 的問題做回答,否則使用者天馬行空,<請你跟我結婚>、<誰是世界上最美麗的女人>這種問題滿天飛,結果你的 chatbot 完全聽不懂,畢竟人人都以為人工智慧可以做到穿熱褲黑絲襪又會拌嘴的傲嬌助手,實際上還不是血汗工程師在後面拉線設定的人工智障,隨便一問就被看破手腳。


另外我們要新增一個 Intent ,命名為 favorite color,注意這個命名在未來連接 webhook 時非常重要,命名跟大小寫都要注意。
在 training phrase 的地方,試著打入一些使用者可能會說的話,最好是上一句問句的回話:
blue
my favorite color is red
orange is my favorite
ruby is the best
邊打的時候 dialogflow 就會自動的把顏色部分給標起來,接著往下拉到 Action and parameters ,系統應該會自動加上一個 color 的 parameter,我們設定 entity 為 @sys.color,value 為 $color。


這裡就能看出 dialogflow 服務的功力了,在我們打上例句的時候 dialogflow 就透過事先分類好的 entity,分析出我們現在想要知道使用者回話的什麼內容(這裡是顏色),再幫我們把這個內容存到變數裡。
最後在 response 的地方,我們使用剛剛萃取出來的內容:
OK. Your favorite color is $color
這樣就完成我們的回話機器人了,當然我們要測試一下,在 Integration 選擇 integration setting,記得打開 Auto-preview changes 後再點 test,就可以在 google action 測試頁面中測試了:

短短小文祝大家新年快樂,希望大家都有個機器人陪你過年(欸

2019年12月14日 星期六

用 Qt Graphics 做一個顯示座標的工具 - 細節調整

其實這篇才是我寫文的主因,前面兩篇其實都是前言(欸),總之就是我埋頭自幹 AZ 大大吃一頓飯就寫得出來的工具,結果過程中寫得亂七八糟,有時候該出現的東西就是不出現測到頭很痛,或是測一測就進無窮迴圈後來發現是自己蠢,發了 v0.1 之後被靠北顯示怎麼這麼醜,想改又很難搜不到解法QQ。
當然最後還是搜到了,但就希望可以多寫一篇文,讓這些解法更容易被搜到,如果運氣很好真的幫到人也算功德一件,如果你真的被幫到的話,麻煩幫我這篇文章留下一個 like 然後順手按下旁邊的訂閱和小鈴鐺(醒醒這裡是 blogger 不是 youtube。

畫上座標線:

這算是一個附加功能,一般的顯示工具只要設定 Scene 的 background brush,設定一個黑色的 brush,就能畫出黑色背景了。
但如果我們要更精細的話,就要去實作 scene 或是 view 的 drawBackground 函式:
void drawBackground(QPainter *painter, const QRectF &rect)

下面是我實作的程式碼節錄,有幾個可以注意的地方:
  1. Qt 的座標系統左上角是 0,0,往右往下是 x 遞增跟 y 遞增,所以用 top 的值會比 bottom 小。
  2. 這個函式參數是一個 QPainter 跟一個 QRectF,painter 好理解就是目前作畫的對象,在這個 painter 上面作畫就會畫在背景上;QRectF 一般會認為就是現在顯示背景的矩形,不過不對,它是「現在要更新背景的範圍的矩形」。
    例如我現在顯示的 Scene 大小是 -50,-50 ~ 50, 50,但我實作滑鼠可以在 scene 上面拉一個選取的小框框,在我拉框框的時候,Qt 會判定只有這個小框框裡面的背景是需要重畫的,QRectF 就會設定到這個小框框上;其實某種程度來看這樣也對,而且更節省重畫的資源。
  3. 承上點,所以有抓到現在 scene 的大小要怎麼辦?這也是我為什麼選擇是實作 View 的 drawBackground 而不是 Scene 的,因為我可以透過 mapToScene(viewport()->rect()).boundingRect() 取得這個 View 現在顯示 Scene 的大小。
後來就是一些數學計算,在對應的座標點上呼叫 drawPoint 了,下面的 code 我有略為刪節過了,要用的話不要全抄。
void
MyViewer::drawBackground(QPainter *painter, const QRectF &rect) {
  qreal left         = rect.left();
  qreal right      = rect.right();
  qreal top         = rect.top();
  qreal bottom = rect.bottom();

  QRectF sceneRect = mapToScene(viewport()->rect()).boundingRect();
  qreal size = qMax(sceneRect.width(), sceneRect.height());
  qreal step = qPow(10, qFloor(log10(size/4)));

  qreal snap_l = qFloor(left / step) * step;
  qreal snap_r = qFloor(right / step) * step;
  qreal snap_b = qFloor(bottom / step) * step;
  qreal snap_t = qFloor(top / step) * step; 

  // print coordinate point
  for (qreal x = snap_l; x <= snap_r; x += step) {
    for (qreal y = snap_t; y <= snap_b; y += step) {
      painter->drawPoint(x, y);
    }
  }

  // print coordinate line
  painter->drawLine(qFloor(left), 0, qCeil(right), 0);
  painter->drawLine(0, qCeil(bottom), 0, qFloor(top));
  QGraphicsView::drawBackground(painter, rect);
}

不動物件:

第一個是所謂的不動物件,也就是 QGraphicsItem 透過設定了 ItemIgnoresTransformations flag,這樣這個 item 就不會受到 view 視角變化的影響。

使用情境也很單純,像是在畫面上打個 marker 或是寫上文字,如果視窗縮小就看不到就奇怪了,所以這個 marker 就要設定這個 flag,放大縮小都會顯示一樣。
改變也就是呼叫一下
setFlag(QGraphicsItem::ItemIgnoresTransformations, true);
就可以了。

要注意的是在設定了這個 flag 之後,在這個 item 裡面的位移似乎會失去效果(還是行為會變很怪,我有點忘了),一般要在一個位置例如 100, 100 畫一個正方形,我們可以用 QGraphicsRectItem,在 100, 100 的地方畫正方形;如果是 ignore transformation 的物件,我是變成在 0,0 的位置畫一個正方形,然後把物件的位置用 setPos 設定在 100, 100。
這部分當初真的弄超久,後來覺得這樣不行,把放在 dropbox 裡面的 <c++ GUI Programming With Qt 4> 拿出來翻翻,沒想到在第八章 Qt graphics 章節就講了要怎麼寫類似的東西,還有範例 code ,果然寫程式還是要多看書而不是瞎攪和,弄了好一陣子的東西其實書上的範例都寫了。

填充物:

上一篇文的最後一張圖,應該很明顯可以看到,我用 brush 填進去的東西,非常的…不均勻,一格一格的非常醜,實際運作的 code 也是,只要放大縮小填充物的就會變得不連續。
這是因為 QBrush 在填東西的時候,用固定密度在填充,不會隨著螢幕的放大縮小改變填充物的密度,要修成也只需要一行,在 paint 函式裡面加上這個:
QBrush m_brush;
m_brush.setTransform(QTransform(painter->worldTransform().inverted()));
painter->setBrush(m_brush)
把現在場景的變形反轉補償回去就可以了;這個解答出自 Stack Overflow

隨放大縮小調整長度:

同樣的是另一張圖的箭頭,在放大縮小的時候,箭頭的部分會跟著放大縮小,這是我們不想要的,因為縮太小的時候箭頭會看不到,這時候就要用到我們上篇提到的 QStyleOptionGraphicsItem,在 paint 函式裡面,可以用這個東西從 painter 的 transform 裡取得 level of detail (LOD):
qreal qScale = option->levelOfDetailFromTransform(painter->worldTransform());
qreal len = 10 / qScale;
qScale 就是目前放大值,可以用它調整我們要畫的長度 len;這個解答出自 Heresy's Space

下面列一下參考書目:
  • C++ GUI Programming With Qt 4
  • Game Programming using Qt 5 Beginner's Guide: Create amazing games with Qt 5, C++, and Qt Quick

2019年12月11日 星期三

用 Qt Graphics 做一個顯示座標的工具 - 客製化元件

上一篇我們介紹了 Scene, View, Item 的關係,這篇就來客製化一下,畢竟 Qt 的元件沒客製化功能都非常受限,預設行為幾乎什麼都沒有,這時候就是好好重溫 C++ 最美妙功能––繼承的時候了。
首先是 Scene 跟 View,都用繼承的方式建一個自己的 class,才能在裡面實作各種信號跟插槽,設計上 Scene 是 View 的 data member,View 上面接到什麼東西直接 pass 給 Scene,實作就不一一介紹,下面是一個簡單的修改列表,因為是內部用的工具就沒辦法把程式碼貼上來給大家笑了

View實作事件:

  • keyPressEvent:客製化按鍵盤的行為。
  • wheelEvent:連接到放大縮小的函式。


View實作插槽:

  • clearScene:清空畫面
  • addItem:接收讀進來的物件,往下直接呼叫 Scene 的 addItem。
  • zoomToAll 跟 zoomRect:放大縮小。


Scene 實作事件:

  • mousePressEvent/mouseMoveEvent/mouseReleaseEvent:定義滑鼠行為。


Scene 實作信號:

  • rectSelected:滑鼠事件會發出這個信號,通知 View 跟 MainWindow。
  • mouseClick:同樣用來通知 MainWindow 使用者點在哪裡。


當然我們也要客製化自己的 QGraphicsItem,當然如果要顯示的東西沒太多特別要求,只靠 Qt 提供的那些 QGraphicsXXXItem 也是 OK 的。
我記得在實作時候也有考慮過是不是繼承 QAbstractGraphicsShapeItem 就好,後來好像因為什麼原因,還是繼承 QGraphicsItem。

照著 Qt 的說明文件,要實作自己的 QGraphicsItem,重點在打造兩個函式:
QRectF boundingRect() const override
這個函式要回傳一個 QRectF,標示這個 Item 的大概位置,讓 Scene 能用它作分類跟檢索,如果設錯的話就有可能變成 item 明明在某個位置視窗卻打死不顯示它,因為 Scene 在檢索的時候就不認為這個 Item 有需要顯示。
void paint(
  QPainter *painter,
  const QStyleOptionGraphicsItem *option,
  QWidget *widget) override
Paint 就是操作 Painter 裡面的函式盡情的作畫,不過我自己是省得麻煩,都是創一個 QPainterPath 然後呼叫 Painter 的 drawPath,這樣比較簡單。
舉例來說我自己實作的一個物件是在視窗上面標上一個箭頭然後顯示文字,大略來說程式碼就是這樣:
QPainterPath path;
path.moveTo(0, 0);
path.lineTo(0, 8);
path.lineTo(5.656, 5.656);
path.closeSubpath();
path.lineTo(7.65, 18.48); // len 20, tilt 22.5
path.setFillRule(Qt::WindingFill);
QFont serif("Helvetica", 12);
path.addText(QPoint(0, 0), serif, m_text);
paint 裡再用 drawPath 把這條 path 畫出來就可以了。

Paint 的另外兩個參數 QStyleOptionGraphicsItem 和 QWidget,前者帶著顯示上要用的參數,在下篇做一些細部設定的時候會用到;後者則指向目前繪圖中的物件,通常可以不用管它,我也還不確定什麼時候會用到它。
這次實作全面採用 C++ 的新關鍵字 override,個人認為真的好用,像是上面的 boundingRect 如果沒寫 const 的話其實是在實作不同的函式,加了 override 編譯器就會跳錯誤,比較不會犯這種不容易注意到的錯。

實作 QGraphicsItem 以我們的應用這樣就夠了,如果還需要更精細的管理,可以再實作函式:
QPainterPath shape() const
這個函式用來檢查碰撞、滑鼠有沒有點在物件上等等,不實作預設就是以 boundingRect 代替。

自己幹完 QGraphicsItem 之後,整個程式也有了個樣子了,繼承自 QGraphicsItem 的物件可以直接用 addItem 塞進 Scene 裡面,下面截兩張運作起來的樣子,分別是顯示箭頭跟一個三角形的多邊形:



還有很多細部設定沒做所以看起來會有一點粗糙,下一篇預定就是要講這些細部的東西。
Related Posts Plugin for WordPress, Blogger...