標籤: 資料驅動 Schema

  • 把 HR 表單系統接上 A2A:讓 AI 幫新人填表與上傳證件

    重點摘要

    • 把一個 Flask 做的 HR 新人入職表單系統,加一層 A2A(Agent2Agent),讓外部 AI agent 能查狀態、填表、上傳證件。
    • 接得起來的關鍵不是協定本身,而是資料驅動的 form_schema:同一份 schema 同時驅動表單、驗證、PDF、指標,也直接當成 A2A 的機器契約吐給對方 agent。
    • 把關留給人:身分證重複「偵測不擋」、上傳檔只檢 magic bytes 不判真偽——系統只負責收得到、開得了,真偽由 HR 人工核對。
    • 一個血淋淋的工程坑:回歸測試在同一個 live 資料庫上 reseed,把使用者正在填的資料反覆洗掉。測試一定要隔離。

    前一篇〈A2A 是什麼:用一個 Python 把資料庫變成 AI 外掛技能〉講的是概念。這篇是實戰續篇:我把一個真的在跑的 HR 新人入職表單系統接上 A2A,讓外部 AI agent 可以幫新人「查件、填表、上傳證件」,而且全程把關都還在。過程中最有價值的領悟,跟最痛的坑,都寫在這裡。

    背景:一個全假資料的 HR 入職 demo

    這個系統是給 HR 看的過渡期工具:新人報到前在線上把入職資料填好(基本資料、聯絡、銀行、學歷、委任函簽回、證件上傳…),取代「郵寄紙本表單、報到日逐張人工核對」。技術上是 Flask + SQLite + vanilla JS,全假資料、不接任何正式系統,但架構為「日後接真」預留。

    核心是一份 form_schema.py:16 個欄位群組、34 個必填欄位、4 個 repeater、條件顯示(男性才有兵役欄)。整個系統是資料驅動的——加一個欄位群組,前端表單、即時驗證、預填 PDF、HR 指標統計全部自動跟著生。這個設計後來成了接 A2A 時最大的本錢。

    為什麼這個系統「天生」適合 A2A?

    A2A 的三個核心原語,剛好對上這個系統本來就有的三個特性:

    A2A 原語 系統本來就有的東西
    機器可讀的能力契約 資料驅動的 form_schema——直接就是「要填什麼、什麼格式、什麼規則」的契約
    input-required 任務狀態 流程本來就 human-in-the-loop:委任函要人簽、HR 要審、重複證號要人工核對
    非同步、長命任務 入職流程本來就跨天(委任函三個工作天內簽回)

    換句話說,A2A 不是要硬塞進來的東西;它只是在同一套核心邏輯上,再加一個「給 agent 用」的介面。原本給人用的 Web UI 一行都不用動。

    四個 skill:查、填、傳檔

    實作上就是兩個端點:GET /.well-known/agent.json(Agent Card,宣告能力)和 POST /a2a(JSON-RPC 2.0,method 一律 message/send)。對方 agent 要呼叫哪個 skill,就把 skill 名放進訊息裡。四個 skill:

    • get_requirements(唯讀、公開)→ 把 form_schema 當機器契約吐出
    • get_status(唯讀、帶 token)→ 回案件狀態與完成度,不含任何個資
    • submit_data(寫入)→ 填/更新欄位,可半填累積,回逐欄錯誤;final=true 才正式送出
    • upload_document(寫入)→ 上傳證件/照片(PNG/JPEG/PDF,≤5MB)

    填表的呼叫長這樣——agent 先半填,系統回逐欄錯誤,agent 照著補,直到 ok:true 再帶 final=true 送出:

    POST /a2a
    {"jsonrpc":"2.0","id":1,"method":"message/send",
     "params":{"message":{"parts":[
       {"kind":"data","data":{"skill":"submit_data","token":"<TOKEN>",
         "data":{"name":"王小美","mobile":"0912000333"},"final":false}}
     ]}}}
    
    // 回傳: {"ok":false, "errors":[{"field":"birthday","label":"出生年月日","message":"此欄為必填"}, ...],
    //        "progress":{"pct":15,"done":5,"total":34}}

    關鍵資產:把 schema 當契約吐出去

    外部 agent 要幫人填表,最痛的是「不知道要填哪些欄、什麼格式、什麼規則」。一般系統得另外寫一份文件或 OpenAPI;而這個系統因為是資料驅動的,get_requirements 直接把 form_schema 轉成乾淨的 JSON 契約丟過去——欄位 key、型別、是否必填、選項、條件顯示、repeater 子欄位,全都在。

    這就是「資料驅動設計」在接 A2A 時直接變現的地方:本來要額外做的「能力描述」,變成免費的副產品。同一份 schema,人看到的是表單,agent 看到的是契約。

    把關留給人:偵測不擋、不判真偽

    開放給 AI 寫入,最該想清楚的是「哪些要擋、哪些只標記」。兩個實際決定,都選擇偵測但不阻擋,把判斷交給人:

    • 身分證重複:直覺上身分證唯一,該擋重複。但台灣確實有不同人合法持相同身分證字號的歷史案例(戶政歷史錯誤)。硬擋會把真人擋在門外。所以改成偵測到就在 HR 送件頁標記「請人工核對」,仍允許送出。
    • 上傳檔的真偽:系統只檢 magic bytes(檔頭簽名)——把 virus.txt 改名 id.png 騙不了,因為內容開頭騙不了。但 magic bytes 只能證明「這是一張合法的 PNG」,不能證明「這是真的身分證」。一張 1×1 的空白 PNG 也過。所以系統明確不宣稱判真偽,那是 HR 的事。

    這條原則救了一次:demo 時對方 agent 把照片用 submit_data 塞成 base64 字串,系統「來者不拒」收進了資料欄,但 HR 頁面讀的是上傳表,顯示「未上傳」。看起來像 bug,其實是「值照存、把關在顯示/驗證層」。修法不是讓它更寬鬆,而是讓 submit_data 遇到檔案欄就回 error、明確指引去用 upload_document——把錯誤導向正確的通道,而不是默默吞下

    最痛的坑:測試別在 live 環境上 reseed

    這是整個過程最值得記的教訓。我有個習慣:每改一次 code,就自動跑全套回歸測試,而每個測試前都會 seed.py 把資料庫砍掉重建,確保乾淨狀態。問題是——那個資料庫,就是對外 demo 正在用的同一個

    於是使用者在做 live A2A demo、外部 agent 正在填表的同時,我在旁邊「自己驗證自己的 code」,每跑一輪就把對方填的資料洗掉一次。使用者連問三次「為什麼資料一直不見」我才意識到:我把「我的開發測試」當成看不見的背景工作,但它跟使用者的正式環境是同一台 server、同一個 DB

    正解是測試隔離:資料庫路徑、埠號、debug 模式全部吃環境變數,測試跑在獨立的 data/test/ + 另一個埠,跑前後驗證 live 資料庫的修改時間沒變,來證明真的沒碰到。一個小插曲:第一次選的測試埠被機器上別的服務佔了,回的是英文 HTML,害測試的 .json() 解析爆掉——所以測試埠要挑冷門的、並先確認是空的。

    更廣的一條:不要在使用者正在用的共用 live 系統上,未經告知就跑會破壞狀態的操作。而且使用者說「我的資料怎麼不見」時,要用證據查清楚再回答——我一開始斷定「是我的 reseed」,查 server log 才發現那個時間點其實是 agent 自己的寫入(檔案修改時間任何寫入都會更新,分不出來源)。先看 log,承認不確定,別急著講一個乾淨的故事。

    結語:協定是介面,schema 是資產

    把一個系統接上 A2A,真正的工作量不在協定信封(那很薄),而在你有沒有一份機器讀得懂的能力描述、以及想清楚哪些把關不能交給機器。資料驅動的 schema 讓前者幾乎免費;human-in-the-loop 的紀律讓後者不出事。協定只是讓 agent 進得來的那扇門,門後該有的東西,還是得自己先蓋好。