iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Build on Google AI

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

Day 26|走出自己房間之前:乾淨環境與雙 LINE 視窗的驗收規格

  • 分享至 

  • xImage
  •  

山友在訊號不穩的地方多按一次,志工稍晚才看到通知,原本那張單還算不算數?Day 25 預告今天要用兩個 LINE 視窗跑完同一張單,這件事還沒做到:Day 19 的收件匣和 Day 22 的停止開關仍是隔離範例,線上服務的單據則在 Firestore。今天先把乾淨容器與雙視窗要收齊的證據寫成嚴謹的驗收規格;真機紀錄預計在 10/12 示範凍結前完成,再回補本篇。

This chapter defines two separate acceptance paths: reproducing the pinned LOCAL code in a clean environment, and tracing one service request across two real LINE accounts. It connects user confirmation, notification, pause controls, atomic claiming, and authorized status retrieval. Existing offline evidence is retained; the new container run and dual-window demonstration remain pending until their own logs and device observations are collected.

一、今日契約卡:走出房間的無干預重現與雙視窗驗收規格

現場一句話:鄉親已經按了送出,換一個人、另一支手機接手,還能找到同一張單嗎?
只准後端決定的規則:確認才建立請求;暫停阻擋新操作,已確認單據仍可授權查回。
Google AI 用到/刻意不用:Gemini 理解詢問、ADK 安排工具;認領、授權與完成判定交由後端。
五分鐘入口:有 Docker 執行第三節的 bash cleanroom.sh;無 Docker 則切至 9f1d98e 安裝 examples/day25/requirements-core.txt,執行 python3 -m unittest examples.day25.test_ingestion -v。
這篇不能證明:離線測試通過,不代表 LINE 已送出;LINE 接受推播,不代表志工手機已看到;志工看到卡片,也不代表已有人認領。

Day 25 已完成五題 Gemini 抽取、47 項離線測試與三筆候選採用(測試店甲與兩版測試店乙),並非三間真實店家;店家讀取工具尚未接上 LINE 服務。[1]

本篇驗收對象 已有依據 本次新增紀錄
安裝與離線契約 Day 24 的 62 項、Day 25 的 47 項歷史測試 乾淨容器:NOT_RUN
真人接手 Day 19 收件匣、Day 22 停止與認領契約 雙 LINE 視窗:NOT_RUN
操作友善度 既有地方服務的操作經驗 本篇整理設計理由,非新增訪談

今天要學會的,是把「另一個人接得起來」寫成能實際核對的條件。 同一個單號走到哪裡,就只說明到哪裡。

二、為什麼要走出自己的房間?從本機跑通到乾淨環境獨立重現

我的電腦記得很多事:安裝過的套件、終端機裡的變數,甚至早就存在的測試資料。這些方便,交給別人時可能全部消失。程式沒改,結果卻不同;原來被省略的,正是第一次使用的人最需要知道的部分。

原本規劃請 3 位外部開發者試用,現改為可自行驗證的乾淨環境。容器讓安裝條件受控,文件是否好懂則是另一個問題;既有營運經驗作為介面依據,不把容器當成真人報告。

無干預重現是預先寫好命令,執行中不補套件、不改程式、不借私人檔案;失敗時保留現場,修正後另開一輪。這是一套查明版本與失敗點的交付紀律,非對所有機器作無條件承諾。

容器比雲端 CI 多證明了:斷網測試確認無隱蔽外部呼叫、從公開網址取得程式確認公開 Repo 完整可用、換至純淨基底驗證安裝相依、且任何人在本機皆可重現。CI 驗明已提交程式與測試清單,新容器留存安裝日誌,雙手機則提供通道與真人依據。

三、五分鐘路徑:全新乾淨環境的相依安裝與驗證

乾淨環境並非毫無依賴。主機仍需 Docker 與 Bash,建置需要網路;Python、Git 與相依套件在映像內準備。「五分鐘入口」指操作起點,下載時間另計。

以下固定 Day 25 工程基線 9f1d98e,其歷史 CI 為 Run 37897811593;不是本次容器已通過的證明。後續只改文件的版本與程式測試基線分開記錄,避免每次修一句話都換掉實驗對象。[1]

在專案根目錄將下段存成 cleanroom.sh,以 bash cleanroom.sh 執行。它從公開 Repo 重新取得程式,沒有掛入既有程式資料夾、私人金鑰或套件快取;唯一主機掛載是新建的報告目錄。[2]

set -euo pipefail
RUN=${1:-out/day26/cleanroom-$(date +%Y%m%d-%H%M%S)}
REV=9f1d98e967b6a804b85df3b2aacd92bde3d60835
mkdir -p out/day26
mkdir "$RUN"
docker pull python:3.13-slim
BASE=$(docker image inspect python:3.13-slim --format '{{index .RepoDigests 0}}')
echo "$BASE" > "$RUN/base.txt"
docker build --pull --no-cache --build-arg BASE="$BASE" --build-arg REV="$REV" \
  --iidfile "$RUN/image.id" - <<'DOCKERFILE' 2>&1 | tee "$RUN/build.log"
ARG BASE
FROM ${BASE}
RUN apt-get update && apt-get install -y --no-install-recommends \
    git ca-certificates && rm -rf /var/lib/apt/lists/*
ARG REV
WORKDIR /work
RUN git clone https://github.com/jarsing/local-service-agent-for-line.git repo \
    && cd repo && git checkout --detach "$REV" \
    && test "$(git rev-parse HEAD)" = "$REV"
WORKDIR /work/repo
ENV PYTHONDONTWRITEBYTECODE=1 PIP_NO_CACHE_DIR=1
RUN python -m pip install -r examples/day25/requirements-core.txt \
    && python -m pip check
DOCKERFILE
IMAGE=$(cat "$RUN/image.id")
docker image inspect "$IMAGE" > "$RUN/image.json"
docker run --rm --network none \
  --mount "type=bind,source=$(cd "$RUN" && pwd),target=/evidence" \
  "$IMAGE" bash -euo pipefail -c '
    git rev-parse HEAD > /evidence/commit.txt
    python -VV > /evidence/python.txt 2>&1
    python -m pip freeze > /evidence/packages.txt
    python -m unittest examples.day25.test_ingestion -v \
      > /evidence/day25.log 2>&1
    python -m unittest examples.day24.test_evidence -v \
      > /evidence/day24.log 2>&1
    python -m unittest examples.day19.test_inbox_contract examples.day22.test_security -v \
      > /evidence/day19_day22.log 2>&1
    python -m examples.day25.demo --out out/day26/demo-01 \
      > /evidence/demo.log 2>&1
    cp out/day26/demo-01/report.json /evidence/demo.json
    git status --porcelain --untracked-files=all > /evidence/worktree.txt
    test ! -s /evidence/worktree.txt
  '

安裝階段允許下載,測試階段以 --network none 強制斷網以防暗中依賴。set -euo pipefail 即時中斷異常,日誌各自寫入;重跑另建新目錄以保存現場。[2]

預期應看見 Day 25 的 47 項、Day 24 的 62 項、以及 Day 19 與 Day 22 的 46 項測試通過,Demo 顯示合成輸入、外部呼叫 0、重送同號。這是驗收條件,不能把預期輸出貼成執行結果。

還有一個容易漏掉的細節:Python 3.11 起,unittest 探索不再支援命名空間套件。Repo 的 examples/ 沒有 __init__.py,所以不能用 -s examples 一次掃完;逐日探索時要加 -t . 指定根目錄,Day 25 的相對匯入才找得到所屬套件。不是找到 test_*.py 就代表能正確匯入。[3]

python -m unittest discover -s examples/day25 -t . -p 'test_*.py' -v

python:3.13-slim 是會更新的標籤,這次取得的是哪一版,以 base.txt 的映像摘要和 python.txt 為準。容器只測得到有執行的程式,所以另外用 git grep 掃過公開 Repo:Day 8 的 run.py 沒有設定金鑰時,原本會去讀開發電腦固定位置的 .env,證據也預設寫到 Repo 外。commit 662c0ad 已改成先讀環境變數,沒有時只找目前目錄或範例目錄裡的 .env(都列在 .gitignore),輸出預設寫到 out/day08/;ci.yml Run 38044471367 通過。

圖 1:乾淨環境驗收設計
圖 1:乾淨環境驗收設計。公開程式版本 → 相依安裝 → 斷網測試 → 獨立報告;47 項、62 項與 46 項是固定基線的預期測試數,容器結果另行記錄。

四、雙 LINE 視窗驗收規格:如何對齊同一個單號與跨端因果

兩支手機分別登入使用者與志工帳號,才有跨角色的可見證據。同一人可持有兩個測試帳號,但截圖應說明是受控演練,不稱為外部人士的使用回饋。

待測問句為:「我想帶吃全素的長輩吃爌肉飯,可以幫我詢問店家嗎?」模型先釐清範圍,提議轉接時出示摘要,待確認才由後端建單;想問店家和同意送出,是兩個不同動作。

Day 19 收件匣與 Day 22 停止開關均為隔離範例,尚未串接真實 LINE 與 Cloud Run。實機前須定出驗收規格:確認步驟、通知派送、認領與查回全數綁定同一權威 request_id,不可兩端各自建單拼湊。[4]

接線前已知有兩處要先處理:權威單據是線上服務 Firestore 的 requests 文件,單號是 req- 加日期與 16 碼 16 進位,不是 Day 22 範例的 SQLite 與 UUID;Day 11 的查回與 Day 13 的回條畫面目前只接受 pending_human_review,志工一認領,原本的查回就會判定回條不符。接線時要把不會變的綁定欄位和會前進的狀態分開核對。

驗收順序如下,表內列的是應取得的證據,尚非手機實錄:

時序 操作與應觀察結果 對帳依據
A 確認 使用者核對摘要並確認送出 同意綁定的內容、操作識別
後端建立 單據與待通知紀錄寫入資料庫 資料版本、通知識別與原單號
B 收到 志工視窗看見待處理卡片 真實截圖及通知接受回條
暫停 管理者停止新操作,B 嘗試認領受阻 控制狀態、拒絕原因、單據未變
恢復 B 重新取得目前狀態並認領 同一單號及認領版本更新
A 查回 使用者查到 human_claimed 授權查詢、資料庫狀態與可見回覆

已確認通道接受的通知不重派;狀態未知時依原 retry key 處理,晚到回條不得倒退已提交狀態。LINE Messaging API 接受推播(HTTP 200)與手機真機收到是不同證據;重試鍵能避免通道重複收單,但非送達保證。通知逾時先對帳,不可換鍵盲目重送。[5]

志工按鈕送回的角色欄位不是授權。先驗 Webhook 簽章,再用事件身分核對志工名單;群組有看到卡片,也不代表群內每個人都能接單。公開截圖遮蔽個資,內部原始記錄保留可比對關係。[6]

human_claimed 表示已有人接手,還不是餐點有貨或店家已同意預約。介面文案可寫「已有志工受理,正在確認」,這是顯示規格,正式文字要由實際狀態決定。

圖 2:雙 LINE 視窗驗收時序設計
圖 2:雙 LINE 視窗驗收時序設計。同一個 request_id 串起確認、通知、PAUSED 攔截、SERVING 認領與查回;架構箭頭不是實機成功證據。

五、停止閘門與原子認領:當系統急停時,志工如何不踩雷

暫停服務時,鄉親已經確認的事仍然存在。停止開關應阻擋新的建單與認領,保留本人查回原單的能力;恢復後也不能把已派送通知再發一次。

Day 22 有兩個不同的版本數字。epoch 是控制世代編號(控制版本),用來拒絕停止/恢復前取得的舊建單許可;claim_version 是這張單的認領版本。現有 claim() 在同一交易讀取停止狀態,再做條件更新,並沒有接收 expected_epoch。接線時必須保留這個區分。[4]

CAS(Compare-and-Swap)指「資料符合原讀取條件才准修改」。不是先查沒人認領、回到 Python 再無條件寫入,而是在更新條件中同時檢查版本、狀態與尚無接手者。欄位沿用 state、claimed_by、claim_version。

下面是沿用 SecurityStore 的本機教學片段。它使用合成身分與新的 SQLite,檢查暫停、恢復與依序的第二次認領;沒有 LINE sender,也不是兩位真人同時點擊的紀錄。在固定版本 Repo 根目錄存為 stop_gate_example.py,再執行 python3 stop_gate_example.py。[4]

import json
from pathlib import Path
from examples.day22.circuit_breaker import SecurityStore, Paused, demo_policy

root = Path("out/day26/claim-01")
root.mkdir(parents=True, exist_ok=False)
store = SecurityStore(root / "claims.sqlite3", demo_policy())
store.set_mode("sample-operator", "SERVING", "demo")
epoch = store.admit("sample-user")
request = store.confirm("sample-user", "sample-operation", "meal_inquiry", epoch)
rid, version = request["request_id"], request["claim_version"]
store.set_mode("sample-operator", "PAUSED", "incident")
try:
    store.claim("sample-volunteer", rid, version)
except Paused as exc:
    if str(exc) != "SERVICE_PAUSED":
        raise AssertionError(f"UNEXPECTED_EXCEPTION: {exc}")
else:
    raise AssertionError("PAUSED_CLAIM_ACCEPTED")
if store.read("sample-user", rid) != request:
    raise AssertionError("REQUEST_MUTATED_WHILE_PAUSED")
store.set_mode("sample-operator", "SERVING", "recovered")
if not store.claim("sample-volunteer", rid, version):
    raise AssertionError("FIRST_CLAIM_REJECTED")
if store.claim("sample-second-volunteer", rid, version):
    raise AssertionError("SECOND_CLAIM_ACCEPTED")
current = store.read("sample-user", rid)
if current["state"] != "human_claimed" or current["claimed_by"] != "sample-volunteer":
    raise AssertionError("CLAIM_STATE_MISMATCH")
if current["claim_version"] != version + 1:
    raise AssertionError("VERSION_NOT_INCREMENTED")
report = {
    "scope": "LOCAL_SQLITE_SYNTHETIC_IDENTITIES",
    "request_id": rid, "state": current["state"],
    "external_calls": 0, "line_device_test": "NOT_RUN",
}
(root / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))

請注意,第二次 False 代表更新未成功,不一定只是另一位志工接手,也可能是卡片版本過期;呈現回覆前仍要授權查回。至於多連線並行的互斥保證,Day 22 已有 test_two_volunteers_claim_one_version 單元測試(兩條執行緒、兩條連線,結果一真一假),不可將上述依序呼叫當成並行測試。[4]

SQLite 交易裡成功,也不代表原本的 Firestore 已具備同一條接線。真機驗收要確認權威資料存在哪裡,將停止狀態與認領條件放在同一筆原子交易;跨兩個資料庫各自成功,仍可能留下不一致。

六、在地服務的操作設計:從訊號不穩到地方志工的防呆

我在奇步應用做地方服務,曾承作彰化縣政府官方 LINE「愛玩彰化」的集章系統,包含卦山大縱走與彰化蔬食節。奇步經營的「彰化旅行+」,則與白色方塊工作室、旅庫彰化共同推出《爌肉之城》等內容;LOCAL 是個人開源教學專案,不是公部門或民間帳號的功能,兩邊的資料與服務責任各自獨立。

既有營運經驗顯示:戶外訊號不穩、螢幕反光或久候連按,不同年齡的使用者皆會遇到。保留設計餘裕是體貼情境,而非特定群體標籤。

重複點擊要保護的是已確認內容。同一操作識別與相同內容查回原單;內容變更則重新確認。重試若順手產生新識別,再周全的重複檢查也會把它當新操作。

舊卡片則是另一種時間差。按鈕還在聊天記錄裡,業務狀態卻已改變;後端核對期限、版本與狀態,給出明確引導,不讓鄉親自行承擔誤觸結果。

現場困擾 工程處理 應觀察的使用者結果
訊號慢,連按送出 同操作重試,內容變更重新確認 找回原單,不要求再填一次
翻回舊訊息按認領 身分、版本與狀態一起核對 提示查最新狀態,保留已完成的資料
查不到店家 區分無結果、暫不可用、不支援 說清楚缺什麼,以及下一個可用動作

Day 17 已把三種查不到分開為無結果(no_data)、暫不可用(query_unavailable)與不支援(unsupported)。介面先回答有依據的部分,再指出待查之處;能留詢問時提供確認入口,無電話或班表時不拿客氣話補上。志工應看見待查資訊,避免將推測轉述為承諾。

圖 3:在地服務操作痛點與工程防呆矩陣
圖 3:既有營運經驗與防呆設計對照。重複操作、舊卡片與查無結果各自對應不同驗收;這是設計整理,非本日新增訪談或成效統計。

七、MCP 與 Agentic:工具公開到哪裡,檢查就跟到哪裡

Agentic 在此指依意圖選擇工具並安排下一步。Gemini 辨識詢問、ADK 安排呼叫,但模型建議不可替代使用者同意與資料庫狀態。

MCP 提供跨系統工具通訊介面。但在單一應用內部,直接呼叫原生 Python 函式更精簡且免去多餘抽象階層。若將 Google Cloud 官方 Firestore MCP 寫入工具直接交由模型,將繞過 admit() 與停止閘門;工具公開到哪裡,授權與副作用檢查就必須守到哪裡。[7]

八、結語與明天預告:Demo 鎖定與三分鐘展示劇本

三分鐘展示先定節奏,時間是劇本配額,不是實測延遲:

時間 畫面與動作 要說清楚的判斷
0~30 秒 指出固定版本與乾淨環境報告 別人的電腦需要哪些準備
30~75 秒 A 確認詢問,B 收到同號通知 接受通知和真人接手分開
75~120 秒 暫停認領受阻,恢復後成功認領 已確認單據保留,狀態由後端決定
120~180 秒 A 查回同號,展示舊按鈕處理 真人受理仍不等於店家答覆完成

Day 26 的新容器與雙視窗驗收需各自真實紀錄,完成前維持待驗收。實機後收斂為同版程式、部署版本、原單號與日誌對照;乾淨重現以 Commit 9f1d98e 與 CI Run 37897811593 為基線,本機片段以 report.json 保存,只依真實產物填入。

下一篇 Day 27|上台前要鎖住什麼?Demo 版本與備援錄影,要固定展示版本、DEMO.md 與備援錄影。雙視窗真機驗收預計在 10/12 示範凍結前完成,完成後回補本篇;備援錄影只播放真正錄過的操作,並向觀眾說明。

走出自己的房間,是把自己早就知道的事情重新交代清楚。鄉親少填一次、志工少猜一次,背後都要有能查回的依據。 模型可以建議下一步,但不能代替完成判定。

程式與參考資料

[1] Day 25 已發布內容、Day 24 已發布內容;本篇前置重現固定 9f1d98e 工程基線。
[2] Docker:空建置上下文、None 網路驅動、容器執行與網路。
[3] Python:unittest 測試探索。
[4] 固定基線的 Day 19 收件匣、Day 22 停止與認領實作。本篇本機片段使用這份 API,非新增線上處理器。
[5] LINE:重試鍵與送達界線。
[6] LINE:Webhook 簽章驗證。
[7] MCP:規格索引與工具架構(2026-07-28);Google Cloud:Firestore 與 MCP 工具箱。


上一篇
Day 25|Gemini 結構化輸出抽取地方資料:從公開文字到服務資料庫
下一篇
Day 27|成果展示要鎖住什麼?Demo 版本凍結、三分鐘劇本與離線備援
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言