本日核心價值 (Core Focus): 用 JSON Schema 把 AI 輸出鎖成
CodeChangePlan,在 Chat Completions 以response_format約束,再用 Pydantic 二次校驗;無效就 fail closed,絕不把散文送進 Codex。
概念說明與實戰情境 (Overview)
Prompt 寫「請回 JSON」不夠。模型仍會加 markdown 圍欄、漏欄位、把 risk 寫成中文。對接 C# API 或編排腳本時,一次壞 JSON 就會讓下游 Codex 改錯檔。解法是雙閘門:API 層用 json_schema 約束,程式層用 Pydantic 再驗一次。Schema 過不了就中止,不要「盡量解析」。輸出契約必須能被單元測試鎖死,這才能把 Day 01 的計畫階段變成穩定介面。
關鍵操作與範例 (Implementation & Example)
CodeChangePlan 是本系列的標準計畫物件。下游(Codex、CI、人工審核)只讀這些欄位:要動哪些檔、風險、測試指令、是否需要 PostgreSQL。files 必須是陣列;空陣列表示無變更,應直接退出而不是讓模型自由發揮。
| 欄位 | 型別 | 規則 |
|---|---|---|
summary |
string | 一句話說明行為變更 |
blocked |
boolean | true 時不准改檔,只准讀 questions |
questions |
string[] | blocked=false 時必須是空陣列 |
files |
object[] | path / action / reason;action ∈ create|modify|delete |
risk |
string | low | medium | high;high 必須人審 |
test_command |
string | 可直接在 repo 根目錄執行 |
sql_needed |
boolean | true 時 sql 不得為空 |
sql |
string | PostgreSQL;不需要時為空字串 |
OpenAI Chat Completions 以 response_format.type = json_schema 啟用 Structured Output。模型名用 gpt-4o,可換成團隊現用模型。strict: true 時,Schema 必須宣告 additionalProperties: false,且 required 列出所有屬性。
import json
import os
import sys
from typing import Literal
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError, model_validator
MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") # 可換成團隊現用模型
PLAN_SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": [
"summary",
"blocked",
"questions",
"files",
"risk",
"test_command",
"sql_needed",
"sql",
],
"properties": {
"summary": {"type": "string"},
"blocked": {"type": "boolean"},
"questions": {"type": "array", "items": {"type": "string"}},
"files": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["path", "action", "reason"],
"properties": {
"path": {"type": "string"},
"action": {
"type": "string",
"enum": ["create", "modify", "delete"],
},
"reason": {"type": "string"},
},
},
},
"risk": {"type": "string", "enum": ["low", "medium", "high"]},
"test_command": {"type": "string"},
"sql_needed": {"type": "boolean"},
"sql": {"type": "string"},
},
}
class FileChange(BaseModel):
path: str
action: Literal["create", "modify", "delete"]
reason: str
class CodeChangePlan(BaseModel):
summary: str
blocked: bool
questions: list[str]
files: list[FileChange]
risk: Literal["low", "medium", "high"]
test_command: str = Field(min_length=1)
sql_needed: bool
sql: str = ""
@model_validator(mode="after")
def fail_closed_invariants(self) -> "CodeChangePlan":
if self.blocked and not self.questions:
raise ValueError("blocked=true requires questions")
if not self.blocked and self.questions:
raise ValueError("blocked=false must use an empty questions array")
if not self.blocked and not self.files:
raise ValueError("unblocked plan must list files")
if self.sql_needed and not self.sql.strip():
raise ValueError("sql_needed=true requires PostgreSQL in sql")
if not self.sql_needed and self.sql.strip():
raise ValueError("sql_needed=false requires empty sql")
return self
def request_plan(user_prompt: str) -> CodeChangePlan:
client = OpenAI()
resp = client.chat.completions.create(
model=MODEL,
temperature=0,
messages=[
{
"role": "system",
"content": (
"You output a CodeChangePlan. "
"If context is insufficient, set blocked=true."
),
},
{"role": "user", "content": user_prompt},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "code_change_plan",
"strict": True,
"schema": PLAN_SCHEMA,
},
},
)
raw = resp.choices[0].message.content
if not raw:
raise SystemExit("fail closed: empty model content")
try:
payload = json.loads(raw)
return CodeChangePlan.model_validate(payload)
except (json.JSONDecodeError, ValidationError) as exc:
raise SystemExit(f"fail closed: {exc}") from exc
def gate(plan: CodeChangePlan) -> None:
if plan.blocked:
raise SystemExit("fail closed: blocked " + json.dumps(plan.questions))
if plan.risk == "high":
raise SystemExit("fail closed: high risk requires human review")
if __name__ == "__main__":
prompt = sys.stdin.read()
plan = request_plan(prompt)
gate(plan)
Path = __import__("pathlib").Path
Path("plan.json").write_text(
plan.model_dump_json(indent=2), encoding="utf-8"
)
print(plan.model_dump_json(indent=2))
# 通過後才允許進入 Codex(Day 05)
# subprocess.run(["codex", "exec", "--sandbox", "workspace-write",
# f"Implement plan.json and run: {plan.test_command}"], check=True)
對 C# 後端,同一個 JSON 可反序列化成 record,作為內部 API 的 DTO。不要讓 Controller 直接吃模型散文。
public sealed record FileChange(string Path, string Action, string Reason);
public sealed record CodeChangePlan(
string Summary,
bool Blocked,
IReadOnlyList<string> Questions,
IReadOnlyList<FileChange> Files,
string Risk,
string TestCommand,
bool SqlNeeded,
string Sql
);
合法計畫範例(折扣計算,無 schema 變更):
{
"summary": "Apply discountRate to order line subtotal in OrderService.GetTotal.",
"blocked": false,
"questions": [],
"files": [
{
"path": "src/Orders/OrderService.cs",
"action": "modify",
"reason": "GetTotal currently ignores discountRate."
}
],
"risk": "low",
"test_command": "dotnet test tests/Orders --filter GetTotal_applies_discount_to_subtotal",
"sql_needed": false,
"sql": ""
}
若折扣改為資料庫欄位,則 sql_needed=true,sql 必須是可執行的 PostgreSQL(例如 ALTER TABLE orders ADD COLUMN discount_rate numeric(5,4) NOT NULL DEFAULT 0;)。Schema 無法表達「這段 SQL 能跑」,所以 test_command 仍要包含驗證步驟;SQL 正確性留給 Codex Sandbox 或 DBA 審核,但「有沒有 SQL」這個布林值必須由 Schema 鎖死。
同一份 PLAN_SCHEMA 也可交給 jsonschema 套件當第二道閘門,適合不想引入 Pydantic 的腳本環境。驗證失敗同樣 fail closed,行為必須與 Pydantic 分支一致,不能一邊嚴格、一邊寬鬆。
import jsonschema
def validate_with_jsonschema(payload: dict) -> None:
try:
jsonschema.validate(instance=payload, schema=PLAN_SCHEMA)
except jsonschema.ValidationError as exc:
raise SystemExit(f"fail closed: {exc.message}") from exc
把 plan.json 當內部 HTTP API 的 body:C# Minimal API 用 record 綁定,模型或綁定失敗回 400,不要進入 Codex 佇列。編排器只認三種出口:blocked(補 Context 後重送)、high(開票給人審)、low/medium(交給 Day 05 的 codex exec)。禁止第四種「先改一點再說」。空的 files 在 blocked=false 時視為無效計畫:那代表模型想聊天,不是想交付變更。
成本上也該把 Schema 當閘門。計畫階段用較小、較穩的模型即可;通過契約後才把 diff 與測試迴圈交給 Codex。壞 JSON 若仍往下送,你付的是 Sandbox 時間與錯誤 commit,不是省到 Token。欄位一旦進契約,就不要再從 summary 用自然語言回推意圖——那個回推路徑無法寫單元測試。
對接 C# 時,把 CodeChangePlan 當成一般 DTO:驗證失敗回 400,blocked=true 回 409 並把 questions 給呼叫端,risk=high 回 422 並開人工佇列。只有 200 才觸發 Codex。這樣前後端契約與 AI 契約是同一份 JSON Schema,OpenAPI 也可以直接引用,不必為「AI 專用」再養一套欄位。測試這層閘門時,準備三筆固定 payload:缺 files、sql_needed=true 但 sql 為空、risk 不在 enum。三筆都必須在進模型之後、進 Codex 之前被擋下;這比再寫一則「請務必輸出 JSON」的 Prompt 更有回歸價值。
實務上把驗證拆成兩層斷言,方便回歸。第一層只問「是不是合法 JSON、欄位與 enum 是否齊」。第二層問「跨欄位是否自洽」:blocked 與 questions、sql_needed 與 sql、未阻擋時 files 不得為空。第一層失敗代表模型或 response_format 設定有問題;第二層失敗代表 Prompt 的 Constraints 不夠硬。不要把兩層混成一個巨大 try/except,否則你無法判斷該改 Schema、改 Prompt,還是改閘門。通過兩層之後,才把 test_command 原樣交給下一階段的 Codex Sandbox;中間不要再改寫指令字串,以免計畫與實際驗證分家。閘門測試要進 CI:每次改 Schema 或 Pydantic 不變量,就重跑那三筆固定 payload。
注意事項與常見失敗 (Pitfalls)
json_object 不定 Schema: 模型仍可省略 files 或把 risk 寫成 中。修法:使用 json_schema + strict: true,再用 Pydantic 做跨欄位不變量(sql_needed 與 sql 連動)。strict Schema 漏 required 或允許 additionalProperties: API 會拒請求或 silently 丟掉多餘鍵。修法:每個 object 都設 additionalProperties: false,所有屬性列入 required;可選語意用空字串 / 空陣列,不要省略欄位。JSONDecodeError 與 ValidationError 一律 SystemExit;不要 fallback 成自由文字。high 風險自動進 Codex: Schema 只能限制 enum,不能替你做變更管理。修法:gate() 對 high 與 blocked 直接失敗,等人審。summary: 下游執行不到。修法:sql_needed 與 sql 分欄;C# 端用 record 對應,禁止從 summary 再 parse。本日總結 (Takeaways)
response_format.json_schema 約束模型,Pydantic / record 再驗不變量。blocked、high risk 全部 fail closed,不呼叫 Codex。files[]、risk、test_command、sql_needed 是計畫的最小可行介面。gpt-4o,用環境變數換成團隊現用模型。明日預告 (Next)
明日進入 Day 04 高效 Prompt 模組化:打造可複用的 AI Coding Assistant 指令庫,把 Role / Constraints / Review 規則拆進 prompts/,用載入器組裝,不再複製貼上整份 Prompt。