前言
上一篇 把 PDF 文字與案例放進待核准的估點流程。實作到第二階段時,系統遇到另一個問題:開發機可用 Codex CLI 跑 non-interactive analysis,server 環境則比較適合直接呼叫 OpenAI Responses API。若兩條路各自定義 prompt、JSON 與資料庫寫入,結果很快就會分岔。
這個案例最後沒有做兩套估點功能,而是固定一個 provider contract,讓執行介面可以替換,領域流程、schema validation 與 audit record 保持一致。
原本的做法與問題症狀
最直接的做法是在 server action 裡判斷:
本機 → spawn CLI
正式環境 → fetch API
但如果分支一路延伸到 prompt、output parsing、錯誤格式與 database write,會出現四種差異:
- 同一欄位在 CLI 與 API 使用不同名稱。
- 一邊保證 JSON,另一邊只從自然語言猜 JSON。
- Timeout、temporary files 與 HTTP errors 沒有共同失敗語意。
- 切換 provider 後,無法比較是哪個 input、schema 與 model 產生結果。
另一個看似彈性的方向是讓每個 provider 回傳任意 object,再由 UI 判斷。這等於把不可信 output 擴散到整個 application,任何漏欄位都要等畫面 render 或資料庫寫入才爆炸。
問題是怎麼推敲出來的?
來源把 boundary 放在 AiAnalysisProvider:輸入包含 request、附件文字、flags 與 reference cases;輸出包含 provider、model、input snapshot 與 structured analysis。
Factory 只決定建立哪個 adapter:
test → deterministic provider
local automation → Codex CLI provider
server API → OpenAI provider
未指定 → deterministic fallback
Codex adapter 與 OpenAI adapter 最後都呼叫同一個 structured parser。Estimate runner 再把結果寫成相同的 AiEstimate 與 AnalysisRun。因此 provider 是 infrastructure choice,不是第二套 business workflow。
最後採用的架構
RequestEstimateRunner
→ AiAnalysisProviderInput
→ Provider Factory
├─ Deterministic
├─ Codex CLI Adapter
└─ OpenAI Responses Adapter
→ Structured Analysis Parser
→ AiEstimate
→ AnalysisRun(provider/model/input hash/status)
Codex CLI adapter 負責 process boundary:stdin prompt、read-only sandbox、schema-constrained final message、timeout、temporary directory 與 cleanup。
OpenAI adapter 負責 HTTP boundary:server-side credential、Responses request、strict JSON Schema、AbortController timeout、non-2xx error 與 response extraction。
兩者都不擁有「是否核准」的決策。
關鍵實作
共用介面只暴露領域需要的資料:
interface AnalysisProvider {
analyze(input: AnalysisInput): Promise<{
provider: string;
model?: string;
inputJson: unknown;
output: StructuredAnalysis;
}>;
}
CLI 路徑使用 non-interactive mode,並限制最終輸出:
codex exec
--sandbox read-only
--ask-for-approval never
--ephemeral
--output-schema <schema>
--output-last-message <temporary-output>
-
這裡的 never 不表示放棄安全確認,而是因為 analysis 本身被設為 read-only,不需要執行會改動系統的命令。Temporary output 在 finally 清除。
API 路徑則要求 strict structured output:
const response = await fetch(responsesEndpoint, {
method: 'POST',
signal: controller.signal,
body: JSON.stringify({
model,
input,
text: {
format: {
type: 'json_schema',
name: 'ai_estimate',
schema: analysisSchema,
strict: true,
},
},
}),
});
Provider 回傳後還會再次由 application schema parser 驗證;API schema adherence 與本地 validation 是兩道不同防線。
實際驗證
既有 tests 證明:
- Codex adapter 的 command 包含 read-only、no approval prompt、ephemeral、output schema、working directory 與 stdin。
- CLI 回傳的 final message 會被解析並通過 structured schema。
- OpenAI adapter 呼叫 Responses endpoint,且 request 使用 strict JSON Schema。
- Server-side credential 缺失時會立即失敗,不會送出匿名請求。
- Non-2xx response 會轉成可讀錯誤;timeout 有獨立訊息。
- Estimate runner 會保存 provider、model、input hash、input/output snapshot 與 success/failure status。
- Test environment 使用 deterministic provider,不依賴外部服務。
本次只做 source 與 test contract 查核,沒有執行 live Codex CLI 或 OpenAI request,也沒有公開 executable path、credential、production model 或正式 prompt。
仍然存在的限制
第一,兩個 provider 使用同一 schema,不代表品質、延遲、費用與資料治理相同。正式切換前仍要用固定案例集比較 accuracy、approval delta、timeout 與 failure rate。
第二,來源有 timeout 與 readable error,但沒有 retry/backoff、rate-limit queue、circuit breaker 或 provider fallback。若直接自動重試,還要避免重複計費與重複寫入。
第三,CLI adapter 依賴 local executable、working directory 與使用者 authentication;API adapter 依賴 server credential、network 與 endpoint policy。Deployment checklist 必須分開,不可只測 interface。
第四,AnalysisRun 保存 input/output snapshot 有助稽核,也會增加敏感資料面。需求附件若含個資或商業資訊,要定義 redaction、retention、access control 與 deletion。
第五,Structured Outputs 保證 schema adherence,不保證估點內容正確。合法的 JSON 仍可能漏看需求或引用錯案例。
可以延伸到哪些情境?
Provider adapter 適合內容分類、文件抽取、客服草稿與 code analysis。穩定邊界通常是:
Provider 負責「如何執行」
Schema 負責「資料長什麼樣」
Runner 負責「如何記錄成功與失敗」
Domain workflow 負責「結果能不能生效」
下一篇把最後一條責任拉開來看:AI 可以提出建議,但不能直接核准:Approval Ledger 與權限分離
參考資料
- OpenAI:Codex developer commands
- OpenAI:Structured model outputs
- OpenAI:Responses API
- OpenAI:Data controls
- OpenAI:Production best practices
以上平台文件查核日期:2026-07-31。
