
【實戰】Day 28:HITL — 人工確認節點,AI 建議變真實訂單
Human-in-the-Loop 實作:Mode B 辯論完成後,若總經理建議「接受」,前端顯示 HITL 確認卡;使用者點「核准下單」後,FastAPI 將新訂單寫入 orders.json,訂單管理頁即時刷新。全程零資料庫,純 JSON 持久化示範完整的 AI → 人 → 系統 閉環。
WRITTEN BY

- Name
- Harry Chang
Mode B 辯論做到昨天只是「說說而已」——今天讓 AI 的建議真正改變系統狀態。
- 今日目標
- 架構全貌
- 請求時序圖
- Step 1:data_loader.py 新增 save_orders()
- Step 2:debate.py — HITL 邏輯
- Step 3:Next.js Proxy
- Step 4:DebateResult 型別更新
- Step 5:HitlCard 元件
- Step 6:訂單管理頁跨元件刷新
- 測試流程
- 今日 Checklist
- 小結
今日目標
| 任務 | 說明 |
|---|---|
| 後端判斷 GM 裁決 | gm_approves() 解析總經理文字,決定是否需要 HITL |
| 訂單提案自動預填 | 從問題萃取品牌、查預設廠區 / 規格,組成 order_proposal |
POST /debate/confirm | 接收核准請求,生成 order_id,寫入 orders.json |
| HITL 確認卡 | 前端顯示琥珀色確認卡,核准 / 拒絕兩個按鈕 |
| 跨頁面即時更新 | 核准後 window.dispatchEvent('orders-updated'),訂單頁自動刷新 |
架構全貌
使用者提問「是否接受 Nike 急單?」
│
▼
Mode B 辯論(業務 / 生管 / 財務 / 總經理)
│
├─ finance_veto = true → 紅色否決橫幅,不顯示 HITL
│
└─ gm 裁決包含「接受」 → requires_approval = true
│
▼
HITL 確認卡(前端)
├─ 顯示訂單提案(品牌 / 廠區 / 數量 / 金額)
├─ 「✅ 核准下單」 → POST /api/debate/confirm
│ │
│ └─ 寫入 orders.json → 回傳新 order_id
│
└─ 「❌ 拒絕」 → 僅 UI 標記拒絕,不寫入
訂單管理頁 ──── window.Event('orders-updated') ────→ 自動重新載入
請求時序圖
時序圖 ↔ Step 對照
| 時序圖標註 | 對應 Step | 說明 |
|---|---|---|
| ①②③ | —(Day 25 已實作) | LangGraph 辯論執行 |
④⑤ Note [Step 2-2] | Step 2-2 | gm_approves() 判斷裁決 / extract_brand() 萃取品牌 |
⑥ [Step 2-3/2-4] | Step 2-3 / 2-4 | DebateResponse 新增 requires_approval + order_proposal |
⑦ Note [Step 5] | Step 5 | HitlCard 元件 — 渲染琥珀色確認卡 |
⑧ [Step 5] | Step 5 | handleApprove() — 使用者點「核准下單」送出 |
⑨ [Step 3] | Step 3 | Next.js /api/debate/confirm Proxy |
⑩ [Step 1 + 2-5] | Step 1 + Step 2-5 | save_orders() 寫檔 + debate_confirm() 端點 |
⑭ Note [Step 6] | Step 6 | dispatchEvent('orders-updated') 跨頁刷新 |
Step 1:data_loader.py 新增 save_orders()
之前的 get_orders() 只會讀取,今天需要寫回。在 backend/app/data_loader.py 加入:
def save_orders(orders: list[dict]) -> None:
"""將訂單列表寫回 orders.json(HITL 核准後呼叫)"""
with open(DATA_DIR / "orders.json", "w", encoding="utf-8") as f:
json.dump(orders, f, ensure_ascii=False, indent=2)
Step 2:debate.py — HITL 邏輯
2-1 品牌資料字典
廠區不寫死,改為動態查詢即時產能:
KNOWN_BRANDS = ["Nike", "Adidas", "New Balance", "Puma", "Asics", "Under Armour"]
BRAND_CUSTOMER = {
"Nike": "CUST_A01", "Adidas": "CUST_B02",
"New Balance": "CUST_C03", "Puma": "CUST_D04",
"Asics": "CUST_E05", "Under Armour": "CUST_F06",
}
# 只保留材質 / 單價 / 建議訂量,廠區由 best_available_factory() 動態決定
BRAND_DEFAULTS = {
"Nike": {"insole_type": "EVA", "unit_price": 1.00, "order_qty": 50000},
"Adidas": {"insole_type": "EVA", "unit_price": 0.85, "order_qty": 80000},
"New Balance": {"insole_type": "PU", "unit_price": 1.10, "order_qty": 60000},
"Puma": {"insole_type": "EVA", "unit_price": 0.90, "order_qty": 40000},
"Asics": {"insole_type": "PU", "unit_price": 1.15, "order_qty": 50000},
"Under Armour": {"insole_type": "EVA", "unit_price": 0.95, "order_qty": 70000},
}
def best_available_factory() -> str:
"""即時查廠區,回傳可用產能最充裕的廠區代碼"""
factories = get_factories() # 讀 factories.json
best_code, best_avail = "TW-01", -1
for f in factories:
avail = round((1 - f["current_utilization"]) * f["capacity_per_month"])
if avail > best_avail:
best_avail, best_code = avail, f["factory_code"]
return best_code
以目前數據為例:
| 廠區 | 月產能 | 稼動率 | 可用量 |
|---|---|---|---|
| TW-01 | 200,000 | 72% | 56,000 |
| VN-02 | 350,000 | 91% | 31,500 |
| ID-03 | 280,000 | 58% | 117,600 ← 選這 |
新訂單自動分配到 ID-03(印尼廠),而非固定的台灣廠。
2-2 輔助函式
def extract_brand(question: str) -> str | None:
for brand in KNOWN_BRANDS:
if brand.lower() in question.lower():
return brand
return None
def gm_approves(final_decision: str) -> bool:
# 格式:【最終決策】:接受 / 條件接受 / 拒絕
match = re.search(r'【最終決策】[::]\s*(.+)', final_decision)
if match:
line = match.group(1).strip()
return "接受" in line and "拒絕" not in line
return "接受" in final_decision and "拒絕" not in final_decision
gm_approves() 只解析 【最終決策】 那一行,避免後文出現「接受」就誤判。三種情況:
| GM 決策 | gm_approves() |
|---|---|
| 接受 | True |
| 條件接受 | True |
| 拒絕 | False |
2-3 DebateResponse 新欄位
class OrderProposal(BaseModel):
brand_name: str
factory_code: str
customer_id: str
insole_type: str
order_qty: int
unit_price: float
class DebateResponse(BaseModel):
# ... 原有欄位 ...
requires_approval: bool = False
order_proposal: Optional[OrderProposal] = None
2-4 在 agent_debate() 判斷並組建提案
brand = extract_brand(request.message)
approved = gm_approves(result["final_decision"])
requires_approval = approved and brand is not None
if requires_approval and brand:
d = BRAND_DEFAULTS[brand]
order_proposal = OrderProposal(
brand_name=brand,
factory_code=best_available_factory(), # 動態選最有餘裕的廠
customer_id=BRAND_CUSTOMER[brand],
insole_type=d["insole_type"],
order_qty=d["order_qty"],
unit_price=d["unit_price"],
)
2-5 新增 /agent/debate/confirm 端點
class ConfirmRequest(BaseModel):
order_proposal: dict
@router.post("/agent/debate/confirm")
def debate_confirm(request: ConfirmRequest):
orders = get_orders()
# 生成下一個 order_id
ids = [int(o["order_id"].replace("ORD-", ""))
for o in orders if o.get("order_id", "").startswith("ORD-")]
next_num = max(ids) + 1 if ids else 1001
proposal = dict(request.order_proposal)
proposal["order_id"] = f"ORD-{next_num}"
proposal["order_date"] = date.today().isoformat()
proposal["shipped_qty"] = 0
proposal["status"] = "pending"
proposal["total_amount"] = round(
proposal["order_qty"] * proposal["unit_price"], 2
)
orders.append(proposal)
save_orders(orders)
return {"status": "success", "data": proposal}
Step 3:Next.js Proxy
src/app/api/debate/confirm/route.ts:
import { NextRequest, NextResponse } from 'next/server';
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8000';
export async function POST(request: NextRequest) {
const body = await request.json();
const upstream = await fetch(`${BACKEND_URL}/api/v1/agent/debate/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!upstream.ok) {
return NextResponse.json({ error: `Backend error: ${upstream.status}` }, { status: upstream.status });
}
return NextResponse.json(await upstream.json());
}
Step 4:DebateResult 型別更新
在 agent-chat.tsx 新增介面,讓 TypeScript 知道新欄位:
interface OrderProposal {
brand_name: string;
factory_code: string;
customer_id: string;
insole_type: string;
order_qty: number;
unit_price: number;
}
interface DebateResult {
// ...原有欄位...
requires_approval: boolean;
order_proposal: OrderProposal | null;
}
Step 5:HitlCard 元件
type HitlStatus = 'pending' | 'loading' | 'approved' | 'rejected';
function HitlCard({ proposal }: { proposal: OrderProposal }) {
const [status, setStatus] = useState<HitlStatus>('pending');
const [createdId, setCreatedId] = useState('');
async function handleApprove() {
setStatus('loading');
try {
const res = await fetch('/api/debate/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_proposal: proposal }),
});
if (!res.ok) throw new Error(`後端回應 ${res.status}`);
const json = await res.json();
setCreatedId(json.data?.order_id ?? '');
setStatus('approved');
// 通知訂單管理頁重新載入
window.dispatchEvent(new Event('orders-updated'));
} catch {
setStatus('pending');
}
}
if (status === 'approved') return (
<div className='mt-3 rounded-md border border-green-300 bg-green-50 p-3 text-sm text-green-700 ...'>
✅ 訂單已核准 {createdId} — {proposal.brand_name} {proposal.order_qty.toLocaleString()} 雙
</div>
);
if (status === 'rejected') return (
<div className='mt-3 rounded-md border border-gray-200 ...'>
❌ 訂單已拒絕 — 不寫入系統
</div>
);
return (
<div className='mt-3 rounded-md border border-amber-300 bg-amber-50 p-4 ...'>
<p className='mb-3 text-xs font-semibold text-amber-700'>
⚡ 人工確認(HITL)— 總經理建議接受此訂單,請確認後執行
</p>
<table className='mb-4 w-full text-xs'>{/* 品牌 / 廠區 / 數量 / 金額 */}</table>
<div className='flex gap-2'>
<Button size='sm' onClick={handleApprove} className='bg-green-600 text-white'>
{status === 'loading' ? '處理中…' : '✅ 核准下單'}
</Button>
<Button size='sm' variant='outline' onClick={() => setStatus('rejected')}
className='border-red-300 text-red-600'>
❌ 拒絕
</Button>
</div>
</div>
);
}
最後在 DebateCard 底部插入:
{data.requires_approval && data.order_proposal && (
<HitlCard proposal={data.order_proposal} />
)}
Step 6:訂單管理頁跨元件刷新
OrdersTable 已在 Day 27 末尾建立,它監聽 orders-updated 事件:
useEffect(() => {
const handler = () => loadOrders(brandFilter, statusFilter);
window.addEventListener('orders-updated', handler);
return () => window.removeEventListener('orders-updated', handler);
}, [brandFilter, statusFilter]);
HitlCard 核准成功後:
window.dispatchEvent(new Event('orders-updated'));
兩個完全獨立的頁面(/dashboard/agent 與 /dashboard/orders),透過瀏覽器全域事件同步狀態,不需要 Redux 或 Context。
測試流程
啟動服務
# Terminal 1 — FastAPI
cd backend && uvicorn app.main:app --reload
# Terminal 2 — Next.js
cd frontend/next-shadcn-dashboard-starter-main && npx next dev
測試 HITL(接受情境)
- 開
localhost:3000/dashboard/agent - 模式切「⚖️ 多角色辯論」,身份選「總經理」
- 點快速問題「我們應該接受 Nike 的急單嗎?」
- 等待辯論完成 → 底部出現 琥珀色 HITL 卡

- 確認訂單資訊(Nike / ID-03 / 50,000 雙 / $50,000)

- 點「✅ 核准下單」→ 卡片變綠色,顯示新
ORD-XXXX
驗證訂單寫入
- 切換到
localhost:3000/dashboard/orders - 可看到最新一筆
ORD-XXXX,狀態待出貨
測試 HITL(拒絕情境)
- 辯論問題「是否接受 Puma 的新訂單?」
- Puma 毛利率 ~12%,低於紅線 → 財務否決觸發
- 財務否決 =
true時,GM 通常裁決「拒絕」
- 結果:不顯示 HITL 卡(
requires_approval = false)
今日 Checklist
-
data_loader.py:新增save_orders() -
debate.py:extract_brand()、gm_approves()、OrderProposal -
debate.py:DebateResponse加requires_approval+order_proposal -
debate.py:POST /api/v1/agent/debate/confirm端點 -
src/app/api/debate/confirm/route.ts:Next.js Proxy -
agent-chat.tsx:HitlCard元件 +DebateCard整合 - 跨頁
orders-updated事件機制
小結
今天最重要的工程概念:
HITL 閉環:AI 不直接寫資料,先給使用者看「提案」,使用者按鈕觸發才真正執行。這是企業 AI 部署最關鍵的安全機制。
gm_approves()防誤判:用 regex 解析【最終決策】那一行而非全文搜尋,避免「財務建議拒絕,但總經理說... 在某條件下可接受... 拒絕...」這種長文誤觸發。window.dispatchEvent跨元件同步:兩個不相關的頁面組件(Agent Chat ↔ Orders Table),用瀏覽器全域事件就能同步,不用 Redux / Zustand,輕量且直接。JSON 持久化:
save_orders()直接覆寫 JSON 檔案,示範了最輕量的 HITL 持久層。生產環境換成INSERT INTO ordersSQL 即可,介面不變。
明天 Day 29:Docker Compose 一鍵啟動,前後端都容器化,最終交付給任何人都能跑。