iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Build on Google AI

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

Day 19|志工真的接到單了嗎?最小真人通知與收件匣閉環

  • 分享至 

  • xImage
  •  

半夜問兩碗爌肉飯,系統留下詢問,另一端卻可能沒人知道。今天把建單、通知、認領與結案拆成可核對的狀態,先用 SQLite 重現收件匣與舊按鈕競爭,再界定真人驗收需要的證據。讀者帶走的是一條能接回既有服務單、失敗時也知道下一步的交接路徑。

A saved request is not a delivered notification, and a delivered notification is not a human commitment. This chapter separates those responsibilities through a small volunteer inbox. A SQLite example exercises conditional claims, durable notification intent, and recovery after an uncertain sender response. External delivery and human acknowledgement remain separate acceptance steps. The result is a runnable local contract and a concrete checklist for connecting the existing LINE service to an accountable recipient.

一、今日契約卡:回條的另一端,要有人接得住

現場一句話:媽媽留下詢問後,畫面說等待人工,另一端真的知道嗎?
只准後端決定的規則:同一單的通知與認領各有紀錄,誰能接手由授權與條件更新決定。
Google AI 用到/刻意不用:沿用 Gemini 與 ADK 的詢問入口;認領、通知狀態與結案由後端管理。
五分鐘入口:python3 -m examples.day19.inbox_contract --out out/day19/first-run。
這篇不能證明:本機替身接受通知,不證明 LINE 送達或真人認領。

元件 本篇角色 實作範圍
Gemini/Google ADK 承接前篇理解問句與工具分工 本次收件匣演練不新增模型呼叫
SQLite 保存收件匣投影、通知意圖與認領版本 本篇新增的隔離範例
Firestore 既有雲端資料的整合目標 交易接線須另驗,不把 SQLite 當雲端部署
Cloud Run/LINE 既有服務與真人接收通道 真實通知、按鈕事件與手機回條另列驗收

建立單據,不等於通知真人;通知送達,也不等於真人受理。 本次先把這條分界做成可執行的本機契約,再把真人端點接入。兩者有各自的證據欄位。

二、昨天留下了詢問,今天追問它去了哪裡

《爌肉之城》把彰化的飲食節奏整理成地方故事。白色方塊工作室、旅庫彰化與我們經營的彰化旅行+,關心的不只是哪碗飯好吃,也包括遊客走進地方之後,遇到問題怎麼得到幫忙。

維運彰化蔬食節與地方 LINE 服務,我很在意「接下來有人處理」這句話。鄉親可能只看懂回條上的幾個字,便放心把手機收起來。系統若只是保存資料,卻寫得像窗口已經接聽電話,等待的成本就全丟給了他。

Day 18 的複合問句是:「現在哪裡有開著的爌肉飯?可以幫我預約兩碗帶走嗎?」固定說明卡提供三個入口,其中一個是「留下服務詢問」。那只是入口;使用者仍要完成既有的確認流程,才有服務單可交接。[1]

我把這次的新能力定義成「讓既有單據有接收端」,而非另外生出第二張單。原本的 request_id 要一路帶到收件匣。半夜的詢問可以等待有權限的人查看,至於幾點值班、多久回覆,應由營運安排承諾。程式沒有值班表,就先不替人答應清晨一定回電。

我也把收件匣和預約系統分清楚。遊客留下「想請窗口協助確認」的詢問,是讓人決定能否協助,不是由系統替店家保留兩碗飯。志工的工作項目可以是回覆資訊、提醒限制或另行聯絡;最後怎麼處理,要有接手者自己的紀錄。這個差別會影響回條的一整句話:寫「已建立詢問」比寫「訂餐處理中」更接近目前真正提供的服務。

第一版設計已有四種狀態與固定文案,但資料放在記憶體字典裡,通知也只建立本機回條。因此,以下把第一版設計與新增的 SQLite 補強範例分開說明,實機接線仍列在第七節,沒有把終端機印出的成功當成真人到場。

三、五分鐘路徑:先在收件匣裡找到同一單

本篇新增程式位於既有專案的 examples/day19/,使用原來 Python 環境。這個範例只用標準函式庫,與正式資料庫隔離;輸出目錄採新名稱,避免蓋掉前次紀錄。

python3 -m unittest examples.day19.test_inbox_contract -v
python3 -m examples.day19.inbox_contract --out out/day19/first-run

打開 REPORT.json,會看到 SQLITE_OFFLINE_FAKE_SENDER、network_calls: 0、兩次認領結果 [true, false],以及 state: human_claimed。單號由演練產生,前綴標示 synthetic;它是測試資料,不是地方遊客的案件。

演練中,測試接手者的名單由程式預先建立,並非從使用者輸入推論。讀者可以先打開收件匣清單,看每張卡只有 request_id、類別、狀態與認領版本,再查使用者端只看得到哪幾個欄位。把兩種視角分開,不只是把同一段 JSON 的字縮小:管理者需要操作依據,使用者需要知道等待進度,兩者不必收到相同資訊。

同一目錄的 inbox.sqlite 有兩張重要表:inbox 保存交接投影,outbox 保存待發通知。後者像寄件前的待辦清單:先寫下要寄哪一單、寄給誰、內容與重試識別,再交給傳送器。關掉 Python 後檔案仍在,後續可以從原狀態接續。

操作 應看到的事實 不應推導的結論
匯入已確認請求 收件匣與通知意圖同時保存 又建立一筆正式服務單
假傳送器接受 通知紀錄變成 accepted LINE 好友已讀
第一位測試志工認領 human_claimed,版本增加 線下服務已完成
第二位使用舊版本認領 條件更新零筆,回覆失敗 原案件被刪除

範例的兩張資料表如下。fingerprint 用來拒絕同號異內容,retry_key 則對應外部通知請求;它們處理不同責任。provider_id 要等通道接受後才填,預先保存的是通知意圖,不是成功證明。

CREATE TABLE IF NOT EXISTS inbox(
 request_id TEXT PRIMARY KEY, owner_ref TEXT NOT NULL,
 category TEXT NOT NULL, fingerprint TEXT NOT NULL,
 state TEXT NOT NULL, claim_version INTEGER NOT NULL,
 claimed_by TEXT, updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox(
 request_id TEXT PRIMARY KEY REFERENCES inbox(request_id),
 recipient_ref TEXT NOT NULL, retry_key TEXT UNIQUE NOT NULL,
 payload TEXT NOT NULL, status TEXT NOT NULL,
 first_attempt REAL, attempts INTEGER NOT NULL DEFAULT 0,
 provider_id TEXT
);

這個最小模型讓每張單只有一份通知意圖。未來要轉交另一個值班群組時,應定義新的通知事件與對帳規則,不能直接沿用原重試鍵卻更換接收者。先把限制寫清楚,比在欄位裡塞進任意收件人更容易維運。

這份投影需要由可信接點匯入。正式入口應先向既有後端核對單據、擁有者與同意狀態,再呼叫 register_confirmed();這個函式名稱本身不是授權證明。使用者查詢則帶入伺服器端辨識的帳號,不能拿別人的單號讀資料。

四、四個狀態,各自只說自己知道的事

第一版用 notification_delivered 表示通知階段。新增範例改用 notification_accepted,刻意把名稱縮回傳送通道接受請求這件事;這是本篇的命名調整,不是悄悄改寫前篇的業務狀態。

request_created
→ notification_accepted
→ human_claimed
→ resolved
狀態 新增範例的判定 使用者可理解的意思
request_created 已保存確認單的交接投影 詢問已保存,等待通知窗口
notification_accepted 傳送器取得通道接受回條 通知請求已被接受,等待接手
human_claimed 已授權接手者完成條件更新 已有人接手處理
resolved 接手者登錄結案 已登錄結案,不據此推論現場服務品質

認領用到 比較並交換(CAS):卡片帶著當時的版本,資料庫只有在版本仍相同、尚無接手者時才更新。像志工在共同登記簿上簽名,重點不是大家都先看一眼空格,而是簽下去時仍只有一個人能取得它。

以下節錄新增 inbox_contract.py。BEGIN IMMEDIATE 讓 SQLite 寫入交易先取得需要的寫入權;正式 Firestore 整合應改用它的交易介面,而非照貼 SQLite SQL。[2][3]

@contextmanager
def transaction(self):
    with self.connection() as db:
        db.execute("BEGIN IMMEDIATE")
        try:
            yield db
            db.commit()
        except BaseException:
            db.rollback()
            raise

def claim(self, request_id: str, volunteer: str,
          expected_version: int) -> bool:
    if type(expected_version) is not int or expected_version < 1:
        raise ValueError("INVALID_VERSION")
    with self.transaction() as db:
        self.authorize(db, volunteer)
        changed = db.execute(
            "UPDATE inbox SET state='human_claimed', claimed_by=?, "
            "claim_version=claim_version+1, updated_at=? "
            "WHERE request_id=? AND claim_version=? AND claimed_by IS NULL "
            "AND state IN ('request_created','notification_accepted')",
            (volunteer, self.clock(), request_id, expected_version),
        ).rowcount
        return changed == 1

rowcount 為一才表示這次取得認領,零表示條件已變。另一位志工失敗後應重新讀取授權範圍內的狀態,再顯示「這筆已有接手者」;不要把資料庫內的完整人員 ID 拼進使用者訊息。範例另外拒絕布林值當作版本號,因為在 Python 中,True 與整數一的關係可能讓太寬的型別檢查放行錯誤輸入。

這裡的授權檢查也在同一交易內。結案時再查一次接手者權限,避免人員已撤權,舊畫面卻仍能修改狀態。對使用者的查詢只回單號、狀態與更新時間;接手者私人識別留在內部紀錄。

五、通知可能重試,案件不能跟著重生

圖 1:志工收件匣最小閉環架構圖。
圖 1:服務單建立、隔離寄件匣(outbox)、通道接受回條、真人樂觀鎖認領與結案宣告架構。框選本機演練範圍,標明通道接受不等於真人受理。

第一版設計以記憶體字典保存寄送紀錄,可防同一物件裡連續呼叫兩次,卻沒有解決重啟或跨實例共享。新增 outbox 把 retry_key、接收者與負載先保存,傳送動作放在交易之外。Firestore 交易可能重做,將外部推播放進交易函式會讓副作用難以控制。[3]

同一個 request_id 再次匯入時,我還核對擁有者、類別與接收端形成的指紋。內容相同才查回既有投影;同號卻換成另一位使用者,直接拒絕。去重若只查「這個鍵在不在」,可能會把一個人的操作錯接到另一個人的單。原單與收件匣的資料庫若分離,兩邊更需要可重跑的對帳程序,而不是宣稱跨資料庫已經有一筆共同交易。

最麻煩的不是明確失敗,而是送出後連線斷了:對方可能已接受,我這邊卻沒拿到回條。此時範例標記 unknown,保留原本通知意圖。重試沿用相同內容與識別,已有接受紀錄則直接查回。

LINE 的 X-Line-Retry-Key 支援推播重試去重;已接受的相同鍵再次送出會有對應回應。這個機制有有效期間,也要求內容與接收者相同,不能延伸成任意時間都能保證送達一次。[4]

保存 request_id、recipient_ref、payload、retry_key
→ 交易提交
→ 呼叫傳送器
→ 取得通道接受回條,另寫通知紀錄

送出後逾時
→ 保留 unknown
→ 有效期間內使用原鍵核對/重試
→ 超過期間先人工對帳,不換新鍵盲送

下面節錄通知器離開交易後的處理。sender 回傳的是通道接受識別;在本機示範裡,它來自 FakeSender。正式 adapter 須從實際 API 回應核對,不能只要沒有丟例外,就自行製造成功回條。

try:
    provider_id = sender(row)
    if not isinstance(provider_id, str) or not provider_id:
        raise ValueError("CHANNEL_ACCEPTANCE_REQUIRED")
except Exception:
    with self.transaction() as db:
        db.execute(
            "UPDATE outbox SET status='unknown' WHERE request_id=? "
            "AND status!='accepted'", (request_id,)
        )
    raise
with self.transaction() as db:
    db.execute(
        "UPDATE outbox SET status='accepted',provider_id=? WHERE request_id=?",
        (provider_id, request_id)
    )
    db.execute(
        "UPDATE inbox SET state='notification_accepted',updated_at=? "
        "WHERE request_id=? AND state='request_created'",
        (self.clock(), request_id)
    )

失敗分類也要保守。輸入或權限錯誤適合先修請求,不是一直重送;網路逾時才有回條不確定的問題。正式傳送器應回報錯誤種類,搭配值班者能看懂的狀態。這份小範例把例外先保留為未知,不提供背景無限重試,也沒有以重試次數換取更漂亮的成功率。

模型不需要參與這個判定。未來若用 Gemini 摘要長句,摘要是待驗證的輔助內容,原確認單才是事實來源。本次沒有這段模型接線,所以收件匣先只顯示類別與單號;查詢詳細內容交給已授權窗口,降低群組通知外洩的風險。

第一版設計的電話遮蔽也只是局部規則,並未處理姓名、地址或各種書寫方式。把部分號碼換成星號,並不等於完成匿名化。我寧可讓第一則通知少一點內容,再讓有需要的接手者進入受權限保護的畫面。

六、一張送出去就過期的卡,教會我的版本界線

第一版設計最有教學價值的地方,是它讓我追到一個很具體的矛盾:產生通知卡時,按鈕帶的是版本一;接著記錄通知已送達時,又把案件版本加成二。照卡片真的帶來的版本去認領,會得到:

CONCURRENCY_CONFLICT_EXPECTED_V1_GOT_V2

第一版設計的示範卻手動傳入二,所以原本的測試仍然通過。這不是少打一個數字;它提醒我,應測使用者實際按到的資料,而非在測試裡替使用者猜新版本。

新增範例分開通知帳務與 claim_version。寄送回條不改認領版本,接手或結案才改。甚至真人按得比接受回條落盤還快,也以認領為準;稍後到的通知紀錄只能補帳,不能把 human_claimed 改回等待通知。

測試所核對的投影可以縮成這樣,以下是欄位示例,不是實機結果:

{
  "request_id": "synthetic-req-001",
  "notification": {
    "retry_key": "retry-case19-notif-001",
    "status": "accepted",
    "provider_id": "fake-line-ack-001"
  },
  "inbox": {
    "state": "human_claimed",
    "claim_version": 2
  },
  "evidence_scope": "SQLITE_OFFLINE_FAKE_SENDER"
}

這樣就能看出,通知帳務仍是接受狀態,業務交接已往前到認領;兩者不必共用同一個容易被覆蓋的狀態欄位。

第二個反例是兩個資料庫連線一起認領。原測試先呼叫第一人,再呼叫第二人,證明的是循序防重;新增測試用兩個執行緒、兩個連線同時競爭,檢查只有一筆條件更新成功。這仍是本機 SQLite 範圍,雲端交易要另驗。

第三個反例是通道接受後遺失回條。替身先保存接受紀錄再拋逾時;本地重試沿用原鍵,最後只有一筆替身接受紀錄。它驗的是重試協定,不是網路只會發出一次請求。

七、從本機演練走到真人,證據分開放

證據 本次狀態 能支持的主張
新增 SQLite 契約自測 十八項通過 版本、雙連線競爭、授權、持久化與不確定回條
本機示範 已執行,外部 API 零次 假傳送器與隔離收件匣可接續
Day 19 同版 CI Commit bfaa74a,9 條全綠 SQLite 收件匣、outbox 與認領契約可重現
既有服務接線 待整合 register_confirmed() 已定義接點,尚未接入既有 LINE 入口
真人端點與推播 未執行;移至 Day 22 外部通道推播與真人驗收待 Day 22 配合秘密與白名單補齊
線上部署狀態 線上服務尚未換上本篇收件匣 本次為隔離契約自測,既有線上服務未做變更

真人端驗收需要把收件匣接進既有 LINE 服務、設定推播用的秘密與接手者白名單,這些正好是 Day 22「權限、秘密與停止開關」的範圍;兩個 LINE 視窗的實機證據,我會在 Day 22 補齊。

手機驗收最好一次完成三個動作:接收端看到待辦,登入的測試志工按接手,原使用者再查一次進度。三張紀錄要能對回同一單號與同一版服務。若只有通知泡泡,驗收到的是可見送達;若只有後端 human_claimed,仍需要核對它是否真由授權者操作。這樣的證據分工,也讓未來展示時知道每個畫面能講到哪裡。

整合時還要從經 LINE 驗簽的事件取接手者身分,而不是相信 Postback 字串傳來的志工 ID。按鈕只提出認領請求,後端仍查授權、單號與版本。[5] 若是我自己操作測試接收端,就寫明是自測窗口,不包裝成地方志工團隊已投入服務。

八、小而真的交接,比龐大的派工畫面更重要

今天的範圍不是客服後台,也沒有值班分派、升級規則或回覆時限保證。讀者先帶走一個可重跑的收件匣契約:原單不重建、通知可查回、接手有版本、查詢有權限。這些條件站穩,才值得接上真正的窗口。

Day 18 教我檢查判分器,今天則檢查回條背後的責任。通知 API 接受、手機看見、真人認領與服務完成,分別需要不同證據。要讓標題裡的「真的接到單」成立,最後一段真人驗收仍得真的走過,不能只把終端機畫面換成一張漂亮圖。

下一篇是 Day 20|模型設定、延遲與每項任務成本:品質先過關,再談快與省 。我會把同一套地方任務的品質、用量與時間放在同一張帳本裡,先辨認哪些是量到的結果,再討論快與省。

程式與參考資料

本篇新增 examples/day19/inbox_contract.py、test_inbox_contract.py。這是隔離補強範例,尚未替換既有 LINE 入口;整合前提見同目錄技術 README。

[1] Day 18:二十題地方契約評測。
[2] SQLite:交易與 BEGIN IMMEDIATE。
[3] Firestore:交易與批次寫入。
[4] LINE:重試失敗的 API 請求。
[5] LINE:驗證 Webhook 簽章。


上一篇
Day 18|20 題地方契約評測:從問句、工具到回覆,連失敗一起留下
下一篇
Day 20|模型設定、延遲與每項任務成本:品質先過關,再談快與省
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言