iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
生成式 AI

練習AI系列 第 16

可插拔對話流程引擎(Workflow Engine for Dialogues)

  • 分享至 

  • xImage
  •  

🆕 程式碼

  1. src/day15_workflow_engine.js(新增:核心引擎)
    // src/day15_workflow_engine.js
    import fs from "fs";
    import path from "path";
    import { openai } from "./aiClient.js";

/** File-based session store(可改 DB) */
const DATA_DIR = path.join(process.cwd(), "data", "wf");
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });

function sessionFile(flowId, sessionId) {
return path.join(DATA_DIR, ${flowId}_${sessionId}.json);
}

function loadSession(flowId, sessionId) {
const fp = sessionFile(flowId, sessionId);
if (!fs.existsSync(fp)) return { flowId, sessionId, state: {}, history: [], done: false };
try {
return JSON.parse(fs.readFileSync(fp, "utf-8"));
} catch {
return { flowId, sessionId, state: {}, history: [], done: false };
}
}

function saveSession(flowId, sessionId, data) {
const fp = sessionFile(flowId, sessionId);
fs.writeFileSync(fp, JSON.stringify(data, null, 2), "utf-8");
}

/** 工具:檢查缺少的欄位 */
function missingSlots(schema, state) {
const required = schema.required || [];
return required.filter((k) => state[k] == null || state[k] === "");
}

/** 工具:依 schema.validators 進行基本驗證(可擴充) */
function validateState(schema, state) {
const errors = [];
if (schema.validators) {
for (const v of schema.validators) {
const ok = v.check(state);
if (!ok) errors.push(v.message);
}
}
return errors;
}

/** 引擎:執行一輪對話 */
export async function runFlowOnce(flow, { sessionId = "default", userInput }) {
const flowId = flow.id;
const sess = loadSession(flowId, sessionId);

// 1) 如果流程已完成,直接回覆完成狀態
if (sess.done) {
return {
reply: flow.ui?.afterDoneMessage?.(sess.state) || "流程已完成,如需重新開始請輸入「重來」。",
state: sess.state,
done: true,
};
}

// 2) 特殊命令
if (/^(reset|重來|重新)$/.test(userInput?.trim() || "")) {
const fresh = { flowId, sessionId, state: {}, history: [], done: false };
saveSession(flowId, sessionId, fresh);
return { reply: flow.ui?.welcome || "已重新開始,請問需要什麼協助?", state: {}, done: false };
}

// 3) 讓流程的 NLU/解析器嘗試從 userInput 填充欄位
const parsed = await flow.parse?.(userInput, sess.state) || {};
// 合併到 state(只覆蓋有值的)
for (const [k, v] of Object.entries(parsed)) {
if (v != null && v !== "") sess.state[k] = v;
}

// 4) Schema 驗證/缺少欄位檢查
const missing = missingSlots(flow.schema, sess.state);
if (missing.length > 0) {
// 請模型「只問一個缺項」,避免一次丟太多造成放棄
const askSlot = missing[0];
const prompt = flow.prompts?.askForSlot(askSlot, sess.state);
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.4,
messages: [
{ role: "system", content: flow.prompts?.system || "你是友善且精準的助理,用繁體中文回應。" },
{ role: "user", content: prompt || 請向使用者詢問欄位「${askSlot}」,以一句話完成。 },
],
});
const reply = res.choices?.[0]?.message?.content?.trim() || 請提供 ${askSlot};
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: reply });
saveSession(flowId, sessionId, sess);
return { reply, state: sess.state, done: false };
}

// 5) 欄位齊全 → 做終檢與條件分支
const errors = validateState(flow.schema, sess.state);
if (errors.length > 0) {
const msg = flow.ui?.validationFailed?.(errors, sess.state)
|| 有些欄位格式不正確:\n- ${errors.join("\n- ")}\n請更正後再試。;
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: msg });
saveSession(flowId, sessionId, sess);
return { reply: msg, state: sess.state, done: false };
}

// 6) 完成前的確認訊息(確認 JSON)
const confirmText = flow.ui?.confirmMessage?.(sess.state);
if (confirmText && !sess.state.__confirmed) {
// 第一次回傳確認訊息;若使用者回「確認/OK」,parse 可填 __confirmed=true
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: confirmText });
saveSession(flowId, sessionId, sess);
return { reply: confirmText, state: sess.state, done: false };
}

// 7) 執行 flow 的完成處理(呼叫 API / 生成單號…)
const result = await flow.onComplete?.(sess.state);
const doneMsg = flow.ui?.doneMessage?.(result, sess.state) || "已完成。";
sess.done = true;
sess.history.push({ role: "user", content: userInput });
sess.history.push({ role: "assistant", content: doneMsg });
saveSession(flowId, sessionId, sess);

return { reply: doneMsg, state: sess.state, done: true, result };
}

/** 註冊中心:你可以在 index.js 收集 flows 用 */
export function registry(flows = []) {
const map = new Map();
for (const f of flows) {
if (!f?.id) throw new Error("flow 必須有 id");
map.set(f.id, f);
}
return map;
}

  1. src/flows/reportRepair.js(新增:報修流程)
    // src/flows/reportRepair.js
    import { openai } from "../aiClient.js";

export const reportRepair = {
id: "report_repair",
schema: {
required: ["name", "phone", "address", "symptom"],
validators: [
{ check: (s) => /^09\d{8}$/.test(s.phone || ""), message: "電話需為台灣手機格式:09xxxxxxxx" },
{ check: (s) => (s.address || "").length >= 6, message: "地址過短,請提供更完整地址" },
],
},
prompts: {
system: "你是報修客服機器人,請用繁體中文簡潔回覆,一次只問一件事。",
askForSlot: (slot, state) => {
const labels = {
name: "請問您的大名?",
phone: "請提供聯絡手機(09xxxxxxxx)。",
address: "請提供報修地址(縣市/區/路段/門牌)。",
symptom: "請描述故障現象(例如:冷氣漏水、無法開機)。",
};
return labels[slot] || 請提供 ${slot};
},
},
ui: {
welcome: "您好,我是報修助手。請問要報修什麼設備?先留下您的稱呼吧~",
confirmMessage: (state) => {
const json = JSON.stringify({
name: state.name, phone: state.phone, address: state.address, symptom: state.symptom,
}, null, 2);
return 已取得以下資訊,請回覆「確認」或「修改」:\n\``json\n${json}\n```; }, validationFailed: (errors) => 欄位有誤:\n- ${errors.join("\n- ")}\n請重新提供。, doneMessage: (result) => 已建立報修單號:${result.ticketId},我們將於 2 小時內致電聯繫。`,
afterDoneMessage: () => "報修已完成;若需新報修請輸入「重來」。",
},
parse: async (input, state) => {
// 簡易規則 + LLM 助攻(抽取姓名/電話/地址/故障)
const out = {};
// 1) 規則抽取(電話)
const m = input.match(/09\d{8}/);
if (m) out.phone = m[0];

// 2) 用 LLM 協助命名實體抽取(避免你自己寫一堆 regex)
const res = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  temperature: 0,
  messages: [
    { role: "system", content: "請從輸入中抽取 name/address/symptom/confirm 四欄,JSON 回覆。" },
    { role: "user", content: input }
  ]
});
const raw = res.choices?.[0]?.message?.content?.trim() || "{}";
const json = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1] ?? raw;
try {
  const obj = JSON.parse(json);
  if (obj.name) out.name = obj.name;
  if (obj.address) out.address = obj.address;
  if (obj.symptom) out.symptom = obj.symptom;
  if (/^確認|ok|OK|是$/.test(obj.confirm || "")) out.__confirmed = true;
} catch { /* 忽略 */ }

if (/^確認|^ok$|^OK$/.test(input.trim())) out.__confirmed = true;
if (/^修改$/.test(input.trim())) out.__confirmed = false;
return out;

},
onComplete: async (state) => {
// 這裡可呼叫你的後端 API 建單,現在先 mock
const ticketId = "T" + Math.floor(100000 + Math.random() * 900000);
return { ticketId, ...state };
},
};

  1. src/flows/customerFAQ.js(新增:客服 FAQ 流程)
    // src/flows/customerFAQ.js
    import { openai } from "../aiClient.js";

export const customerFAQ = {
id: "customer_faq",
schema: { required: ["question"] },
prompts: {
system: "你是客服 FAQ 助理,先判斷是否常見問題;若符合,給精簡答案與下一步建議。",
askForSlot: () => "請描述您的問題(帳單、退費、維修、物流…)?",
},
ui: {
welcome: "您好,這裡是客服常見問題。請說說遇到的情況。",
confirmMessage: null, // 這個流程不需要確認
validationFailed: (errors) => 發生錯誤:${errors.join(", ")},
doneMessage: (result) => result.answer,
},
parse: async (input, state) => {
// 把使用者描述存入 question
return { question: (state.question ? ${state.question}\n${input} : input) };
},
onComplete: async (state) => {
// 直接交給 LLM 回答(正式上線請接你們的 KB/RAG)
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0.3,
messages: [
{ role: "system", content: "簡潔、正確,以條列給出步驟;若需要人工,提供聯絡方式模板。" },
{ role: "user", content: 問題:${state.question}\n請以繁體中文回答。 },
],
});
const answer = res.choices?.[0]?.message?.content?.trim() || "目前無法判斷,請改用人工客服。";
return { answer };
},
};

  1. index.js(修改:加入 task=flow)
    // index.js(新增 flow 分支,其他分支保留)
    import { registry, runFlowOnce } from "./src/day15_workflow_engine.js";
    import { reportRepair } from "./src/flows/reportRepair.js";
    import { customerFAQ } from "./src/flows/customerFAQ.js";

const flows = registry([reportRepair, customerFAQ]);

// ...既有 args 解析保留

async function main() {
const task = args.task || "chat";

if (task === "flow") {
const flowId = args.flow || "report_repair"; // or "customer_faq"
const sessionId = args.session || "demo";
const text = args.text || "哈囉";
const flow = flows.get(flowId);
if (!flow) {
console.log(找不到流程:${flowId}。可用流程:${[...flows.keys()].join(", ")});
return;
}
const { reply, state, done } = await runFlowOnce(flow, { sessionId, userInput: text });
console.log(\n[${flowId}/${sessionId}]);
console.log("AI:", reply);
console.log("State:", JSON.stringify(state, null, 2));
console.log("Done:", done);

} else {
// 你原本的其他 task 分支...
}
}

main().catch(e => { console.error(e); process.exit(1); });

  1. package.json(新增 scripts)
    {
    "scripts": {
    "day15:repair:start": "node index.js --task flow --flow report_repair --session s1 --text '哈囉'",
    "day15:repair:fill": "node index.js --task flow --flow report_repair --session s1 --text '我叫阿德 電話0912345678 地址台北市大安區復興南路一段390號 冷氣漏水'",
    "day15:repair:confirm": "node index.js --task flow --flow report_repair --session s1 --text '確認'",
    "day15:faq:start": "node index.js --task flow --flow customer_faq --session f1 --text '我要退費'",
    "day15:faq:ask": "node index.js --task flow --flow customer_faq --session f1 --text '我在 9/21 下單 但同筆被扣兩次款'"
    }
    }

🗑 建議刪除(若你決定全面使用引擎)

刪除:src/day14_conversation.js

刪除:index.js 內 task=dialogue 分支與 package.json 的 day14:* scripts。

這些功能都由 Day 15 引擎取代,避免重複維護。

▶️ CLI 驗收步驟

報修:啟動、一次填一堆、確認

npm run day15:repair:start --silent
npm run day15:repair:fill --silent
npm run day15:repair:confirm --silent

FAQ:兩輪對話示意

npm run day15:faq:start --silent
npm run day15:faq:ask --silent

會在 data/wf/ 下生成 report_repair_s1.json 等會話檔案。


上一篇
AI 對話任務設計(多輪對話流程、決策樹、狀態管理)
系列文
練習AI16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言