【實戰】Day 28:HITL — 人工確認節點,AI 建議變真實訂單

【實戰】Day 28:HITL — 人工確認節點,AI 建議變真實訂單

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


Mode B 辯論做到昨天只是「說說而已」——今天讓 AI 的建議真正改變系統狀態。

今日目標

任務說明
後端判斷 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') ────→ 自動重新載入

請求時序圖

Loading Diagram...

時序圖 ↔ Step 對照

時序圖標註對應 Step說明
①②③—(Day 25 已實作)LangGraph 辯論執行
④⑤ Note [Step 2-2]Step 2-2gm_approves() 判斷裁決 / extract_brand() 萃取品牌
[Step 2-3/2-4]Step 2-3 / 2-4DebateResponse 新增 requires_approval + order_proposal
⑦ Note [Step 5]Step 5HitlCard 元件 — 渲染琥珀色確認卡
[Step 5]Step 5handleApprove() — 使用者點「核准下單」送出
[Step 3]Step 3Next.js /api/debate/confirm Proxy
[Step 1 + 2-5]Step 1 + Step 2-5save_orders() 寫檔 + debate_confirm() 端點
⑭ Note [Step 6]Step 6dispatchEvent('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-01200,00072%56,000
VN-02350,00091%31,500
ID-03280,00058%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(接受情境)

  1. localhost:3000/dashboard/agent
  2. 模式切「⚖️ 多角色辯論」,身份選「總經理」
  3. 點快速問題「我們應該接受 Nike 的急單嗎?」
  4. 等待辯論完成 → 底部出現 琥珀色 HITL 卡 orders
  5. 確認訂單資訊(Nike / ID-03 / 50,000 雙 / $50,000) orders
  6. 點「✅ 核准下單」→ 卡片變綠色,顯示新 ORD-XXXX orders

驗證訂單寫入

  1. 切換到 localhost:3000/dashboard/orders
  2. 可看到最新一筆 ORD-XXXX,狀態 待出貨

測試 HITL(拒絕情境)

  1. 辯論問題「是否接受 Puma 的新訂單?」
  2. Puma 毛利率 ~12%,低於紅線 → 財務否決觸發
  3. 財務否決 = true 時,GM 通常裁決「拒絕」 orders
  4. 結果:不顯示 HITL 卡(requires_approval = falseorders

今日 Checklist

  • data_loader.py:新增 save_orders()
  • debate.pyextract_brand()gm_approves()OrderProposal
  • debate.pyDebateResponserequires_approval + order_proposal
  • debate.pyPOST /api/v1/agent/debate/confirm 端點
  • src/app/api/debate/confirm/route.ts:Next.js Proxy
  • agent-chat.tsxHitlCard 元件 + DebateCard 整合
  • 跨頁 orders-updated 事件機制

小結

今天最重要的工程概念:

  1. HITL 閉環:AI 不直接寫資料,先給使用者看「提案」,使用者按鈕觸發才真正執行。這是企業 AI 部署最關鍵的安全機制。

  2. gm_approves() 防誤判:用 regex 解析 【最終決策】 那一行而非全文搜尋,避免「財務建議拒絕,但總經理說... 在某條件下可接受... 拒絕...」這種長文誤觸發。

  3. window.dispatchEvent 跨元件同步:兩個不相關的頁面組件(Agent Chat ↔ Orders Table),用瀏覽器全域事件就能同步,不用 Redux / Zustand,輕量且直接。

  4. JSON 持久化save_orders() 直接覆寫 JSON 檔案,示範了最輕量的 HITL 持久層。生產環境換成 INSERT INTO orders SQL 即可,介面不變。

明天 Day 29:Docker Compose 一鍵啟動,前後端都容器化,最終交付給任何人都能跑。