同樣按「取消這次詢問」,一份回覆「已取消」,另一份卻帶回原單。差別在哪裡?今天把 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 卡片,讓介面真正對得起當下後端的狀態。
| 卡片 | 先看懂什麼 | 下一步 |
|---|---|---|
| 確認卡 | 待確認、尚未建單;原文與原確認期限 | 確認送出、取消這次詢問、先看文字版 |
| 回條卡 | 詢問已保存;待人工覆核,尚未通知窗口 | 查詢這筆進度、查看這筆文字版 |
| 狀態提示卡 | 已取消、過期、舊卡失效或結果待查證 | 依提示查原單或目前任務;必要時另提新需求 |

圖 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 的 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。期限到了,為什麼仍能讀回?因為首次新增與讀回歷史結果採不同判斷;只要原任務、本人與當前權限仍符合規則,已經保存的回條就能查。

圖 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 負責進入任務後的確認綁定;自由文字如何帶人開始,是另一項要改善的設計。
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 的實機檢查。
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)關心的是版本能否保持可依需要交付的狀態,不等於每次 Push 都要自動上線。[10] LOCAL 目前把自動測試與人工建置、檢查、部署分開:沿用 Day 12 的服務、資料庫與秘密,換成 examples.day13.main:app,再核對手機結果。
這次我在 Cloud Shell 建置並更新 Day 13 版本,屬於人工控管的修訂版更新;沒有用這次紀錄宣稱已完成逐步增加流量的漸進發布。操作說明保留回復舊修訂版的方式,但流量調整不是瞬間完成,也不會自動撤銷已寫入的請求資料。[9]

圖 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 紀錄 可接著核對。