iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Build on Google AI

LOCAL:30 天打造 LINE × Google AI 地方服務 Agent系列 第 13 篇

Day 13|按鈕還在,就代表還能按嗎?LINE Flex 的狀態與安全操作

  • 分享至 

  • xImage
  •  

同樣按「取消這次詢問」,一份回覆「已取消」,另一份卻帶回原單。差別在哪裡?今天把 Day 12 的確認與回條整理成 LINE Flex,讓鄉親先看懂這件事做到哪裡,再決定下一步。聊天紀錄裡的舊按鈕,則由後端依現在的狀態重新核對。

Day 13 adds fixed LINE Flex cards to the existing local-service agent. Cards describe the state when generated, while the backend rechecks the original task whenever an old action is used. The lesson connects confirmation, cancellation, plain-text alternatives, and a verified template CI run to small, reviewable improvements of the same service.

畫面說到做到:同樣按「取消」,為什麼結果不一樣?

我在手機上按了兩次「取消這次詢問」,得到的卻是兩種完全不同的回覆:一份還沒送出,取消後顯示「本次未建單」;另一份早已保存,再按較早卡片的取消,系統卻把原單找了回來,說明這個按鈕不會撤銷已建立的單據。

這種狀況在真實地方服務裡太常見了。我們團隊在彰化第一線做地方 LINE 系統時(像是卦山大縱走的數位集章),最常碰到的不是系統當機,而是長輩翻出聊天室昨天的舊訊息順手一點,或是公車上訊號微弱時急著連按兩下。鄉親看見一顆按鈕,就會期待它說到做到——如果按鈕還看得見,按下去卻噴出系統錯誤,或者明明辦理中的單據莫名其妙被舊按鈕撤銷,現場的服務窗口就會天下大亂。

今天我們不追求華麗花俏的排版,而是把 Day 12 已經會做的確認與原單查詢整理成 LINE Flex 卡片,讓介面真正對得起當下後端的狀態。

卡片 先看懂什麼 下一步
確認卡 待確認、尚未建單;原文與原確認期限 確認送出、取消這次詢問、先看文字版
回條卡 詢問已保存;待人工覆核,尚未通知窗口 查詢這筆進度、查看這筆文字版
狀態提示卡 已取消、過期、舊卡失效或結果待查證 依提示查原單或目前任務;必要時另提新需求

手機 LINE Flex 狀態確認與舊卡防呆實測
圖 1:左側是待確認卡,期限為 11:12:58;右側在詢問保存後,於 11:29 按舊卡取消,讀回單號 req-20260927-dc352747df76188c。回覆顯示建立時間 11:08:21,並說明這個按鈕沒有撤銷已建立的請求。

畫面要先幫人決定下一步,再來談它漂不漂亮。

一、單號出現了,為什麼仍然是黃色?

Flex Message 是 LINE 用 JSON 描述版面的訊息格式,可以安排標題、內容區塊與按鈕。[1] 本篇選用 bubble,也就是一張訊息卡片,把「哪件事、現在到哪裡、接下來能做什麼」放在固定位置。

我把 pending_human_review 顯示為「待人工覆核」,旁邊同時寫「尚未通知窗口」。這延續 Day 12 的處理狀態:詢問已保存,還沒有一個志工團隊因為卡片出現就接到通知。

另一個狀態 pending_verification 則顯示「結果待查證」。前者有已保存的回條;後者連本次結果都還沒核對清楚。兩者都顯示綠色「完成」,雖然很醒目,卻會讓人誤以為接下來只要等服務送上門。

所以我使用深色標題、黃色狀態區,同時把狀態寫出來。顏色只當輔助;拿掉顏色,文字仍應讓人知道發生什麼。這也呼應無障礙設計中「不要只靠顏色傳達資訊」的原則。[2]

二、模型提供內容,程式決定卡片怎麼操作

一般活動查詢沿用 Day 12:Gemini 提出搜尋參數,Google Agent Development Kit(ADK)串起模型與工具。詢問原文、確認期限、請求單號與處理狀態,則從通過授權檢查的後端服務結果取得。

messages.py 把這些結果整理成固定的顯示資料,再套用模板。以下是 receipt_view() 中的真實節錄,用來確認這個回條模板只處理已支援的業務狀態;它不是可獨立執行的完整程式:

if row.get('status') != 'pending_human_review' or row.get('human_claimed') is not False:
    raise InvalidView('unsupported business state')

這類欄位與值的檢查,是 Schema Validation,也就是核對資料是否符合指定結構與規則。身分授權是另一件事:一份 JSON 長得正確,並不代表送來的人有權顯示那張單。

例如詢問原文含有 {"type":"uri","uri":"https://example.invalid"},程式仍把它放在「詢問原文(使用者提供)」的文字欄位。它不會變成一顆網址按鈕,因為標題、色彩、按鈕與路由都由固定模板建立,沒有把這段文字當成卡片結構合併進去。

為什麼不直接讓 Gemini 產生整張卡?

Gemini 的 Structured Outputs 能依支援的 JSON Schema 約束輸出結構,但只支援其中一部分規格;欄位值與服務語意仍要由應用程式驗證。[6] 本篇沒有另外實測讓模型直接產生完整 Flex,這裡討論的是架構選擇。

卡片上的確認與取消會觸發後端操作。一張格式正確的「已保存」回條,仍可能放了一顆不合時宜的「確認送出」。我希望這項決定能直接由後端狀態核對,而不是再請模型判斷一次。

因此,Gemini 留在理解問句與提出搜尋條件的位置;卡片結構、按鈕與路由由程式決定。這限制的是卡片結構與操作注入,使用者原句本身是否誤導,仍要靠內容標示與服務設計處理。

三、按鈕上的字,和送回後端的資料,是兩件事

Flex 卡片保存的是產生當下的訊息。時間經過後,聊天室裡的舊按鈕仍可能被按到;後端要重新核對,不能只因為按鈕還在就接受操作。

本篇沿用 confirm:<confirmation_id> 與 cancel:<confirmation_id>。Postback 是按下按鈕後送回 Webhook 的操作資料;其中的識別碼用來找回原確認。[3]

下面節錄自 messages.py 的 confirmation_view();cid 是原確認識別,Action 是本專案的 Python 類別:

actions = (Action('確認送出', 'confirm:' + cid, '確認送出這份詢問'),
           Action('取消這次詢問', 'cancel:' + cid, '取消這份詢問'),
           Action('先看文字版', 'text:' + cid, '顯示這份確認的文字版'))

第一個字串是按鈕標籤 label,第二個是送回後端的 data,第三個是顯示在聊天室的文字。Python 欄位叫 display,as_dict() 會把它輸出成 LINE JSON 的 displayText。

這也解釋了照片裡的小差異:按鈕寫「取消這次詢問」,綠色氣泡卻寫「取消這份詢問」。氣泡是顯示文字,後端處理的是 cancel:<cid>,再連同目前身分與狀態一起核對。

確認與取消沿用 Day 12 的路由與服務,取得結果後才換成 Flex。以下兩行節錄自 bridge.py 的 FlexApplication.route():

plan = await super().route(actor, event)
result = plan['result']

所以,今天改善了呈現方式,前幾天定下的規則仍然有效:

後端核對到的狀態 這次如何處理
同一任務已建立請求 讀回原單,不另建一份
尚未建立,原確認已過期 回覆過期,不新增請求
尚未建立,原確認已取消 維持取消;按舊確認也不成立
對話已用「新需求:」換到另一件事 舊卡回覆失效,不替新任務同意或取消
查回受阻,結果仍不明 顯示待查證,不提供再次建單的確認按鈕

取消待確認內容,和撤銷已建立請求,是不同的操作。 本篇實作的是前者。請求已保存,再按舊取消會讀回原單,說明這個按鈕沒有撤單。

圖 1 還有一個細節:原確認期限是 11:12:58,右側操作卻已到 11:29。期限到了,為什麼仍能讀回?因為首次新增與讀回歷史結果採不同判斷;只要原任務、本人與當前權限仍符合規則,已經保存的回條就能查。

手機 LINE Flex 獨立新需求與未建單取消實測
圖 2:左側另提一份新詢問,期限為 11:40:23;右側在確認前取消,畫面顯示「已取消 · 本次未建單」。對照圖 1,這次取消的是尚未送出的確認內容。

四、「查這一筆」不能偷偷變成「查最新那一筆」

每張新回條卡的查詢按鈕都帶回原 confirmation_id,例如 status:<confirmation_id>。Day 13 新增的 read_bound_status() 先比對目前任務,再用原參數查回;讀取後再核對一次目前任務,避免這段期間已經換了另一件事。

若鄉親已提出新需求,按舊回條會看到「這張卡屬於較早的任務」。它不會把新單號冒充舊單。提示卡另提供「查詢目前任務」,讓使用者知道自己接著查的是哪件事。

一般的「查詢原單」文字與 Day 12 舊按鈕,仍查目前任務;新卡則區分「這筆」與「目前」。這是本篇採用的單一目前任務策略,還不是完整歷史單據搜尋。

同樣地,確認期限直接取自資料庫保存的 expires_at,轉成臺灣時間顯示。卡片不會自己倒數;重新顯示或切換文字版,都不會再加五分鐘。讀取只是當下觀察,真正寫入時仍由原服務重新檢查。

五、卡片以外,也要有一條文字路

每個 Flex 都有 altText,用於替代顯示與通知摘要等情境。[1] 我先放狀態、適用單號與下一步,不用「你收到一張卡片」帶過。

回條的摘要格式如下,單號由實際回條填入:

【待人工覆核】詢問已保存。單號為這次的原單號。詢問尚未通知服務窗口;這不是預約成立。可查詢這筆進度或顯示文字版。

完整詢問保留在卡片與文字版。為了讓通知摘要短一點,不應連使用者正在同意的原文也一起截短。

「先看文字版」會依原卡的識別查回確認內容,回覆一般文字訊息;直接輸入「文字版」,則查看目前任務的文字版。確認與取消仍帶原識別,不會因為改成文字呈現就重新取得同意。

文字元件使用 wrap 換行,並設定支援 LINE 字級調整的 scaling。[4] 本篇已保留文字替代與字級設定;實際手機、大字級及 VoiceOver/TalkBack 的使用情況仍要分開測。LINE 也提供訊息物件驗證 API,但 API 接受物件,不等於裝置上的操作體驗已驗證。[1][5]

六、我自己試出的另一個問題:按鈕以外,怎麼開始?

部署後,我在 LINE 輸入「我要預約」,收到:

這次查詢暫時沒有取得可核對的工具結果。原單查詢仍可使用。

這是這次自測收到的回覆。回頭看程式,「需要協助:」與「新需求:」才會進入詢問流程;「我要預約」沒有命中這些入口,會交給一般活動查詢。

這裡必須分開兩件事。query.ask() 發生例外時,程式回覆上述暫不可用訊息;查詢結果回來卻沒有符合活動時,另一條分支會回覆「這份教學快照沒有符合條件的活動」。只看手機文字,還不能把這次原因判成工具正常、只是沒有資料;詳細原因要對照當次紀錄。

不過,使用體驗的問題已經很明確:LOCAL 沒有預約功能,這句回覆也沒有幫提問的人找到適合的入口。我自己知道可以輸入哪些文字,第一次使用的人可不一定知道。

我把它記成下一次改版要處理的問題:清楚說明目前能查活動、留下詢問與查目前任務,並保留「沒有符合資料」和「查詢暫不可用」的不同提示。Postback 負責進入任務後的確認綁定;自由文字如何帶人開始,是另一項要改善的設計。

七、同一個 LOCAL,怎麼一小步一小步改好?

Day 12 已能保存詢問與查回;Day 13 沿用同一個後端,只把狀態、確認、取消與文字版接成更清楚的介面。這次我想完成的增量是:使用者在手機上看懂狀態,按鈕也能走完對應流程。

這呼應敏捷開發(Agile)的小步交付與回饋精神。[7] 上一節是我自己測試時發現的卡點,還不是外部使用者研究;先留下可處理的問題,再用後續修改檢查是否真的改善。每天發一篇文章,不會自動完成一次這樣的循環。

本機檢查:卡片更新,原本的規則還在嗎?

從 Repo 根目錄開始,沿用 Day 12 已建立的相依環境。本篇沒有新增模型或資料庫套件;完整入口與準備條件在 Day 13 操作說明。以下結果放在 out/day13/,請使用自己的測試目錄,並避免把執行產物加入版控。

PY=examples/day12/.venv/bin/python
$PY -m examples.day13.verify --group all --out out/day13/check-01
$PY -m examples.day13.demo --out out/day13/scenarios-01

--group all 執行本篇 34 項模板、30 項流程、3 項建置清單,以及 Day 12 的 45 項核心回歸,合計 112 項。原 Day 12 的 3 項真正 ADK Runner 搭配腳本模型測試,是另外的入口:

$PY -m examples.day12.verify --group adk --origin author_local --out out/day13/previous-adk-01

我的本機紀錄合計 115 項測試執行通過。模板檢查欄位、動作與文字預算;流程測試以合成事件、測試入口與 SQLite 核對連按、取消與舊卡;建置清單測試確認必要檔案會被收進建置內容,並不代替實際 Docker 建置。

手機上這次採用的觀察是確認建單、已保存後按舊取消,以及另一份確認前取消。過期、新任務取代與待查證等分支另由離線演練核對;本次尚未執行 LINE 訊息驗證 API 或 VoiceOver/TalkBack 的實機檢查。

CI:把能反覆檢查的部分留下來

Day 10 已介紹持續整合(CI):經常整合小改動,並由自動檢查提供回饋。[8] 這次新增 .github/workflows/day13.yml,在 Push 到 main 後,或手動觸發時,執行固定模板測試與合成樣本匯出。

以下是該工作流的兩個實際步驟節錄:

- name: Run Day 13 standard-library Flex template tests
  run: python3 -m unittest examples.day13.test_flex -v
- name: Export synthetic Flex sample payloads
  run: python3 -m examples.day13.export_samples --out ci-output/day13-samples

本篇首次 CI 紀錄 對應 d6eb2d88…,其中 34 項模板測試通過,也成功匯出合成樣本。它不需要呼叫 Gemini 或 LINE 業務 API;執行環境的準備與下載仍會連網。

這條工作流是 Push 後檢查,不會撤回已進入 main 的提交,也沒有自動部署 Cloud Run。它守住的是已納入的模板規則,不是把本機 115 項、Firestore 與手機操作都包成一顆綠燈。

CD:新介面沿用原服務,發布仍由人決定

持續交付(CD)關心的是版本能否保持可依需要交付的狀態,不等於每次 Push 都要自動上線。[10] LOCAL 目前把自動測試與人工建置、檢查、部署分開:沿用 Day 12 的服務、資料庫與秘密,換成 examples.day13.main:app,再核對手機結果。

這次我在 Cloud Shell 建置並更新 Day 13 版本,屬於人工控管的修訂版更新;沒有用這次紀錄宣稱已完成逐步增加流量的漸進發布。操作說明保留回復舊修訂版的方式,但流量調整不是瞬間完成,也不會自動撤銷已寫入的請求資料。[9]

Google Cloud Shell 中的 Day 13 Docker 映像建置過程
圖 3:Cloud Shell 正在依 Dockerfile 建置 Day 13 映像,畫面停在相依套件安裝階段。這張圖記錄建置過程,不作為部署完成或流量切換完成的畫面。

Agile 幫我根據回饋選下一個改動,CI 反覆檢查已寫成規則的部分,交付步驟則把指定版本帶到手機。三者接在同一個 LOCAL 上,才不會今天畫一張卡,明天又重做另一套 Bot。

八、讓下一步比「成功」兩個字更有用

這篇想教會讀者的,是讓一個按鈕同時對得上三件事:原本哪份確認、後端現在的狀態,以及使用者接著能做的動作。

Day 12 讓詢問進得了雲端;Day 13 把它整理成看得懂、按錯也有合理回應的介面。這是我想從地方服務經驗整理出來的方法:不只把資訊放到 LINE,而是讓人知道事情走到哪裡。

卡片告訴你這件事做到哪裡,也讓下一個按鈕對得起這個狀態。

你的 LINE Bot 遇過使用者按到很久以前的按鈕嗎?當時怎麼處理?歡迎留言跟我說。

接著,鄉親可能會問:「那這附近有推薦的素食嗎?」下一篇依原題綱接上經同意的偏好與彰化蔬食節店家資料,讓 search_local_places 有來源,也把「今天想吃素」和「以後優先找蔬食,幫我記住」分開處理。

程式與參考資料

前篇:Day 12|換了雲端容器,剛才交代的事還在嗎?第一個 Cloud Run 版本。本篇的 完整程式、操作說明 與 CI 紀錄 可接著核對。


上一篇
Day 12|換了雲端容器,剛才交代的事還在嗎?第一個 Cloud Run 版本
下一篇
Day 14|「今天想吃素」不等於以後都要!經同意的記憶與地方店家查詢
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言