# 第四堂:實務 AI 商業場景開發 ## AI 客服 (資料庫版本) * 由 [week4 版本調整](https://github.com/hexschool/node-training-postgresql) ### NPM 安裝 ```=json "dependencies": { "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "openai": "^4.9.2", "pg": "^8.11.3", "typeorm": "^0.3.17" }, ``` ### db.js ```=JavaScript // db.js const { DataSource, EntitySchema } = require("typeorm"); // 新增對話實體 const Conversation = new EntitySchema({ name: "Conversation", tableName: "CONVERSATION", columns: { id: { primary: true, type: "uuid", generated: "uuid", nullable: false, }, userId: { type: "varchar", length: 100, nullable: false, name: "user_id", }, lastActivity: { type: "timestamp", name: "last_activity", nullable: false, }, createdAt: { type: "timestamp", createDate: true, name: "created_at", nullable: false, }, }, }); // 新增消息實體 const Message = new EntitySchema({ name: "Message", tableName: "MESSAGE", columns: { id: { primary: true, type: "uuid", generated: "uuid", nullable: false, }, role: { type: "varchar", length: 20, nullable: false, }, content: { type: "text", nullable: false, }, timestamp: { type: "timestamp", nullable: false, }, }, relations: { conversation: { type: "many-to-one", target: "Conversation", joinColumn: { name: "conversation_id", }, onDelete: "CASCADE", }, }, }); const AppDataSource = new DataSource({ type: "postgres", host: process.env.DB_HOST || "localhost", port: process.env.DB_PORT || 5432, username: process.env.DB_USERNAME || "root", password: process.env.DB_PASSWORD || "test", database: process.env.DB_DATABASE || "test", entities: [Conversation, Message], synchronize: true, }); module.exports = AppDataSource; ``` ### server.js ```=javascript const express = require("express"); const OpenAI = require("openai"); const cors = require("cors"); require("dotenv").config(); const AppDataSource = require("./db"); const app = express(); const PORT = process.env.PORT || 3000; // 中間件 app.use(cors()); app.use(express.json()); app.use(express.static("public")); // 初始化 OpenAI const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); // 初始化資料庫連結 let dbInitialized = false; AppDataSource.initialize() .then(() => { console.log("資料庫連結成功"); dbInitialized = true; }) .catch((error) => { console.error("資料庫連結失敗:", error); }); // 處理客服查詢 app.post("/api/chat", async (req, res) => { try { if (!dbInitialized) { return res.status(503).json({ error: "資料庫尚未初始化,請稍後再試" }); } const { message, userId } = req.body; if (!message) { return res.status(400).json({ error: "缺少必要參數" }); } // 生成隨機用戶ID(如果沒有提供) const clientId = userId || `user_${Date.now()}`; // 取得對話實體 const conversationRepo = AppDataSource.getRepository("Conversation"); const messageRepo = AppDataSource.getRepository("Message"); // 尋找或建立對話 let conversation = await conversationRepo.findOne({ where: { userId: clientId }, }); if (!conversation) { conversation = conversationRepo.create({ userId: clientId, lastActivity: new Date(), }); await conversationRepo.save(conversation); } else { // 更新最後活動時間 conversation.lastActivity = new Date(); await conversationRepo.save(conversation); } // 新增用戶資訊到資料庫 await messageRepo.save( messageRepo.create({ conversation: conversation, role: "user", content: message, timestamp: new Date(), }) ); // 取得對話紀錄 const messageHistory = await messageRepo.find({ where: { conversation: { id: conversation.id } }, order: { timestamp: "ASC" }, }); // 將對話紀錄轉換為字串 const conversationHistory = messageHistory .map((msg) => `${msg.role === "user" ? "用戶" : "客服"}: ${msg.content}`) .join("\n"); // 構建系統提示 const stockInfo = `- 智慧型手機 X1:庫存 50 - 筆記型電腦 Pro:庫存 30 - 無線耳機:庫存 100 - 智慧型手錶:庫存 45 - 平板電腦:庫存 25`; const systemPrompt = `你是一個專業的客服機器人,專門負責回答商品庫存查詢。 目前商品庫存情況: ${stockInfo} 當用戶詢問商品庫存時,請提供上述最新資訊。如果用戶詢問其他商品,請告知我們目前沒有該商品的資訊。 如果用戶詢問非庫存相關問題,請禮貌地引導他們詢問商品庫存情況。`; // 呼叫 OpenAI Responses API 與 Function Calling const response = await openai.responses.create({ model: "gpt-4-turbo", instructions: systemPrompt, input: conversationHistory, tools: [ { type: "function", name: "provide_stock_info", description: "提供商品庫存資訊給用戶", parameters: { type: "object", properties: { response: { type: "string", description: "對用戶問題的回應", }, }, required: ["response"], }, }, ], tool_choice: { type: "function", name: "provide_stock_info" }, }); console.log(response); // 取得回應內容 const toolCall = response.output.find( (item) => item.type === "function_call" ); const responseData = JSON.parse(toolCall.arguments); // 增加 AI 客服回應到資料庫 await messageRepo.save( messageRepo.create({ conversation: conversation, role: "assistant", content: responseData.response, timestamp: new Date(), }) ); // 回傳結果 res.json({ response: responseData.response, userId: clientId, }); } catch (error) { console.error("處理客服查詢時出錯:", error); res.status(500).json({ error: "處理您的請求時發生錯誤", details: error.message, }); } }); // 啟動伺服器 app.listen(PORT, () => { console.log(`伺服器運行在 http://localhost:${PORT}`); }); ``` ### public/html ```=html <!DOCTYPE html> <html lang="zh-TW"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>客服機器人</title> <style> body { font-family: 'Microsoft JhengHei', Arial, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; color: #333; } .container { max-width: 800px; margin: 20px auto; padding: 20px; background-color: #fff; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } h1 { text-align: center; color: #2c3e50; } .chat-container { height: 400px; overflow-y: auto; padding: 15px; border: 1px solid #ddd; border-radius: 5px; margin-bottom: 15px; } .message { margin-bottom: 10px; padding: 10px; border-radius: 5px; max-width: 80%; } .user-message { background-color: #dcf8c6; margin-left: auto; } .bot-message { background-color: #f1f0f0; } .input-container { display: flex; gap: 10px; } input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; } button { padding: 10px 15px; background-color: #2c3e50; color: white; border: none; border-radius: 5px; cursor: pointer; } button:hover { background-color: #1a252f; } .product-list { margin-top: 30px; } .product-list h2 { color: #2c3e50; border-bottom: 1px solid #ddd; padding-bottom: 10px; } .products { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-top: 15px; } .product-card { border: 1px solid #ddd; border-radius: 5px; padding: 15px; background-color: #fff; } .product-name { font-weight: bold; margin-bottom: 5px; } .product-price { color: #e74c3c; font-weight: bold; } .product-stock { color: #27ae60; margin-top: 5px; } .loading { text-align: center; margin: 20px 0; color: #7f8c8d; } .stock-control { margin-top: 10px; display: flex; gap: 5px; } .stock-control input { width: 60px; } </style> </head> <body> <div class="container"> <h1>智能客服系統</h1> <div class="chat-container" id="chatContainer"> <div class="message bot-message"> 您好!我是您的客服助手。請問有什麼可以幫助您的嗎?您可以詢問商品庫存問題。 </div> </div> <div class="input-container"> <input type="text" id="userInput" placeholder="請輸入您的問題..." /> <button id="sendBtn">發送</button> <button id="clearBtn">清除對話</button> </div> </div> <script> let userId = localStorage.getItem('userId') || null; // 發送訊息到客服系統 async function sendMessage() { const userInput = document.getElementById('userInput'); const message = userInput.value.trim(); if (!message) return; // 加入用戶資訊到聊天介面 addMessage(message, 'user'); userInput.value = ''; // 顯示載入中 const loadingId = addMessage('正在思考...', 'bot'); try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message, userId, }), }); const data = await response.json(); // 移除載入訊息 removeMessage(loadingId); if (data.error) { addMessage(`發生錯誤: ${data.error}`, 'bot'); } else { // 保存用戶ID if (data.userId) { userId = data.userId; localStorage.setItem('userId', userId); } // 添加機器人回應 addMessage(data.response, 'bot'); } } catch (error) { console.error('發送訊息時出錯:', error); removeMessage(loadingId); addMessage('發送訊息時發生錯誤,請稍後再試。', 'bot'); } } // 加入訊息到聊天介面 function addMessage(text, sender) { const chatContainer = document.getElementById('chatContainer'); const messageElement = document.createElement('div'); messageElement.className = `message ${sender}-message`; messageElement.textContent = text; const messageId = Date.now(); messageElement.id = `msg-${messageId}`; chatContainer.appendChild(messageElement); chatContainer.scrollTop = chatContainer.scrollHeight; return messageId; } // 事件監聽器 document.getElementById('sendBtn').addEventListener('click', sendMessage); document.getElementById('clearBtn').addEventListener('click', clearConversation); document.getElementById('userInput').addEventListener('keypress', (e) => { if (e.key === 'Enter') { sendMessage(); } }); </script> </body> </html> ``` ### fine-tuneing 流程 * [文件連結](https://platform.openai.com/docs/guides/fine-tuning) ### 步驟一:準備資料 ### data.jsonl 範例檔案 ```=jsonl {"messages":[{"role":"user","content":"如何使用智慧型手錶 X1?"},{"role":"assistant","content":"您可以先為手錶充電,並在手機上安裝專屬 App,打開藍牙進行配對後,即可開始量測步數、心率及睡眠品質。若需更多功能,請至設定頁面進行個人化調整。"}]} {"messages":[{"role":"user","content":"這款真皮皮包的保養方式是什麼?"},{"role":"assistant","content":"建議使用專業的皮革清潔劑及柔軟布料輕擦,切勿使用含酒精或刺激性成分的化學劑,以免破壞皮質。定期上油保養可延長皮革壽命,儲存時請放在乾燥通風處。"}]} {"messages":[{"role":"user","content":"請問該如何清潔筆電鍵盤?"},{"role":"assistant","content":"可先關機並斷電,再以微吸力吸塵器或壓縮空氣清理縫隙內的灰塵,切勿用水直接擦拭。若有油漬或指紋,請使用不含酒精的電子產品專用清潔濕巾。"}]} {"messages":[{"role":"user","content":"你們的運費怎麼計算?"},{"role":"assistant","content":"本店採用宅配到府,每筆訂單收取固定運費 80 元;若訂單金額滿 2000 元則享免運優惠。偏遠地區及離島運費依照物流公司規定另計。"}]} {"messages":[{"role":"user","content":"如果商品寄到後發現瑕疵,該怎麼辦?"},{"role":"assistant","content":"若您收到的商品有任何瑕疵或損壞,請先拍照留存並於 7 天內聯繫客服,客服人員會協助您申請退換貨並負擔回寄運費。"}]} {"messages":[{"role":"user","content":"要如何辦理退貨?"},{"role":"assistant","content":"在商品簽收後 7 天內未拆封、配件完整且保持原包裝的情況下,可申請退貨。請先聯繫客服取得退貨編號,再依照指示將商品寄回。我們會在收到並確認商品狀況後,於 5-7 個工作天內辦理退款。"}]} {"messages":[{"role":"user","content":"你們提供哪些付款方式?"},{"role":"assistant","content":"我們支援多種付款方式,包括信用卡、ATM 轉帳、行動支付(如 Apple Pay)和貨到付款。請在結帳頁面選擇最方便的方式即可。"}]} {"messages":[{"role":"user","content":"這家品牌怎麼創立的?"},{"role":"assistant","content":"本品牌於 2010 年由熱愛科技與創新設計的兩位大學生創立,初衷是將高品質產品與人性化介面結合,讓消費者能以平易近人的價格享受優質生活。經過多年發展,我們持續追求技術創新與品質提升。"}]} {"messages":[{"role":"user","content":"為什麼你們的商品要強調環保?"},{"role":"assistant","content":"我們相信永續是企業的社會責任,因此在原料選擇與生產過程中盡量採用環保材質,並透過再生能源與減少塑料包裝等實際行動,為環境盡一份力。"}]} {"messages":[{"role":"user","content":"若我的智慧型手錶 X1 壞掉了該怎麼辦?"},{"role":"assistant","content":"我們提供一年保固服務,若非人為因素造成的損壞可免費維修。請先聯繫客服並提供購買憑證、問題描述及照片,經檢測後若符合保固條件,會協助安排維修或更換新品。"}]} {"messages":[{"role":"user","content":"你們有提供海外運送嗎?"},{"role":"assistant","content":"目前我們只提供國內配送,尚未開放海外運送服務。若您有特殊需求,請直接聯繫客服,我們會視情況協助處理。"}]} {"messages":[{"role":"user","content":"購買後要怎麼保養無線耳機?"},{"role":"assistant","content":"建議在每次使用後,用柔軟的乾布或棉花棒擦拭耳機表面及充電盒;若接觸到汗水或水分,務必保持乾燥再進行充電。定期清潔耳塞部分可以維持音質與衛生。"}]} {"messages":[{"role":"user","content":"關於保固服務有什麼需要注意的地方?"},{"role":"assistant","content":"保固只適用於原廠硬體故障,人為損壞(如摔落、進水)或私自拆解則不在保固範圍。請於購買後保留發票或訂單證明,以便後續維修或更換。"}]} {"messages":[{"role":"user","content":"你們家產品最特別的地方是什麼?"},{"role":"assistant","content":"我們主打『質感生活、平易價格』,通過精心設計、嚴格品質控管與合理定價策略,讓更多人能以親民的價格享受高品質科技產品。"}]} {"messages":[{"role":"user","content":"運送大概需要多久時間?"},{"role":"assistant","content":"一般狀況下,週一至週五下午三點前完成訂單,當日即可出貨;出貨後約 1-3 個工作天送達。若遇連假或天候因素,運送時間可能會延誤。"}]} ``` > **注意** > - 每行是一個獨立的 JSON 物件。 > - 不要外加 `[]` 或在最後一行多逗號。 > - 每個物件裡都有 `"messages"` 屬性,且是一個陣列,陣列內包含至少一個 `{"role":"user"}` 與 `{"role":"assistant"}`。 > - 此格式才符合 GPT-4 最新 chat-based fine-tuning 的要求。 ### 步驟二:上傳檔案 (Files API) 1. **準備資料**(以 chat-based 格式) 2. **上傳資料檔案 (Files API)** 3. **啟動 Fine-tuning 工作 (Fine-tuning Jobs API)** 4. **檢查 Fine-tuning 狀態並使用自訂模型** 在 **Postman** 中: 1. 建立 **POST** 請求,URL 設為: ``` https://api.openai.com/v1/files ``` 2. **Headers** 設置: ``` Authorization: Bearer <YOUR_OPENAI_API_KEY> ``` > 另外可加: > - `Content-Type: multipart/form-data` > - 或直接在 Body 選擇 `form-data` 後,Postman 會自動帶入。 3. **Body** 選擇 `form-data`,新增兩個欄位: - `purpose`: `fine-tune` - `file`: (Type 選擇 File),上傳 jsonl 檔案 上傳成功後,會回傳一段 JSON,最重要的是 `id` 這個檔案編號: ```json { "id": "file-ABCDE12345", "object": "file", "bytes": 4567, "created_at": 1692190013, "filename": "training_data.jsonl", "purpose": "fine-tune", "status": "uploaded", "status_details": null } ``` --- ## 3. 建立 Fine-tuning 工作 (Fine-tuning Jobs API) 1. 接著在 **Postman** 新增 **POST** 請求,URL: ``` https://api.openai.com/v1/fine_tuning/jobs ``` 2. **Headers**: ``` Authorization: Bearer <YOUR_OPENAI_API_KEY> Content-Type: application/json ``` 3. **Body** (raw JSON): ```json { "training_file": "file-ABCDE12345", "model": "gpt-4", "n_epochs": 3 } ``` - `training_file`:填入上一步回傳的 `id`。 - `model`: 可觀看此 [model 列表](https://platform.openai.com/docs/guides/fine-tuning#token-limits) - `n_epochs`: 訓練的迭代次數,可自行調整。 送出後會回傳類似: ```json { "id": "ftjob-abcdef123456", "object": "fine_tuning.job", "model": "gpt-4", "created_at": 1692190113, "fine_tuned_model": null, "status": "running", "training_file": "file-ABCDE12345", ... } ``` 其中 `id` 就是這次 Fine-tuning 工作的隨機編號(例如 `ftjob-abcdef123456`)。 --- ## 4. 檢查 Fine-tuning 狀態 1. 建立 **GET** 請求: ``` https://api.openai.com/v1/fine_tuning/jobs/ftjob-abcdef123456 ``` 2. **Headers**: ``` Authorization: Bearer <YOUR_OPENAI_API_KEY> ``` 3. 送出後,若 `status` 為 `succeeded`,代表微調成功。 - 這時會看到一個 `fine_tuned_model` 屬性,例如: ``` "fine_tuned_model": "ft:gpt-4:org-1234:my-custom-model-2025-05-12-12-00-00" ``` - 這 ID 要記住,後續就能用來呼叫你專屬的模型。 --- ## 總結 1. **準備 chat-based JSONL**。 2. **上傳檔案**到 OpenAI Files API(purpose=fine-tune)。 3. **啟動 Fine-tuning** 工作(指定 `training_file`、`model`、`n_epochs` 等)。 4. **查詢狀態**直到 `status = succeeded`,並取得 `fine_tuned_model`。 5. **使用自訂模型**呼叫 ,將 `model` 指向微調後的 ID。 ### code ```=javascript const express = require("express"); const OpenAI = require("openai"); const cors = require("cors"); require("dotenv").config(); const AppDataSource = require("./db"); const app = express(); const PORT = process.env.PORT || 3000; // 中間件 app.use(cors()); app.use(express.json()); app.use(express.static("public")); // 初始化 OpenAI const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); // 初始化資料庫連結 let dbInitialized = false; AppDataSource.initialize() .then(() => { console.log("資料庫連結成功"); dbInitialized = true; }) .catch((error) => { console.error("資料庫連結失敗:", error); }); // 處理客服查詢 app.post("/api/chat", async (req, res) => { try { if (!dbInitialized) { return res.status(503).json({ error: "資料庫尚未初始化,請稍後再試" }); } const { message, userId } = req.body; if (!message) { return res.status(400).json({ error: "缺少必要參數" }); } // 生成隨機用戶ID(如果沒有提供) const clientId = userId || `user_${Date.now()}`; // 取得對話實體 const conversationRepo = AppDataSource.getRepository("Conversation"); const messageRepo = AppDataSource.getRepository("Message"); // 尋找或建立對話 let conversation = await conversationRepo.findOne({ where: { userId: clientId }, }); if (!conversation) { conversation = conversationRepo.create({ userId: clientId, lastActivity: new Date(), }); await conversationRepo.save(conversation); } else { // 更新最後活動時間 conversation.lastActivity = new Date(); await conversationRepo.save(conversation); } // 新增用戶資訊到資料庫 await messageRepo.save( messageRepo.create({ conversation: conversation, role: "user", content: message, timestamp: new Date(), }) ); // 取得對話紀錄 const messageHistory = await messageRepo.find({ where: { conversation: { id: conversation.id } }, order: { timestamp: "ASC" }, }); // 將對話紀錄轉換為字串 const conversationHistory = messageHistory .map((msg) => `${msg.role === "user" ? "用戶" : "客服"}: ${msg.content}`) .join("\n"); // 構建系統提示 const stockInfo = `- 智慧型手機 X1:庫存 50 - 筆記型電腦 Pro:庫存 30 - 無線耳機:庫存 100 - 智慧型手錶:庫存 45 - 平板電腦:庫存 25`; const systemPrompt = `你是一個專業的客服機器人,專門回答各種問題`; // 呼叫 OpenAI Responses API const response = await openai.responses.create({ model: "請填入自己的 model ID", instructions: systemPrompt, input: conversationHistory, }); // 取得回應內容 const responseText = response.output_text; // 增加 AI 客服回應到資料庫 await messageRepo.save( messageRepo.create({ conversation: conversation, role: "assistant", content: responseText, timestamp: new Date(), }) ); // 回傳結果 res.json({ response: responseText, userId: clientId, }); } catch (error) { console.error("處理客服查詢時出錯:", error); res.status(500).json({ error: "處理您的請求時發生錯誤", details: error.message, }); } }); // 啟動伺服器 app.listen(PORT, () => { console.log(`伺服器運行在 http://localhost:${PORT}`); }); ```