import express from 'express'; import axios from 'axios'; import Analysis from '../models/Analysis.js'; import AuditLog from '../models/AuditLog.js'; import { asyncHandler } from '../middleware/errorHandler.js'; import { requireAuth } from '../middleware/auth.js'; import { ollamaConfig, query } from '../config.js'; const router = express.Router(); /** * 從資料庫取得啟用的 LLM 配置 */ async function getActiveLLMConfig() { const [config] = await query( `SELECT provider, api_url, api_key, model_name, temperature, max_tokens, timeout FROM llm_configs WHERE is_active = 1 LIMIT 1` ); // 如果沒有資料庫配置,使用環境變數的 Ollama 配置 if (!config) { return { provider: 'Ollama', api_url: ollamaConfig.apiUrl, api_key: null, model_name: ollamaConfig.model, temperature: ollamaConfig.temperature, max_tokens: ollamaConfig.maxTokens, timeout: ollamaConfig.timeout }; } return config; } /** * POST /api/analyze * 執行 5 Why 分析 */ router.post('/', requireAuth, asyncHandler(async (req, res) => { const { finding, jobContent, outputLanguage = 'zh-TW' } = req.body; const userId = req.session.userId; // 驗證輸入 if (!finding || !jobContent) { return res.status(400).json({ success: false, error: '請填寫所有必填欄位' }); } const startTime = Date.now(); let analysis = null; try { // 取得啟用的 LLM 配置 const llmConfig = await getActiveLLMConfig(); // 建立分析記錄 analysis = await Analysis.create({ user_id: userId, finding, job_content: jobContent, output_language: outputLanguage }); // 更新狀態為處理中 await Analysis.updateStatus(analysis.id, 'processing'); // 建立 AI 提示詞 const languageNames = { 'zh-TW': '繁體中文', 'zh-CN': '简体中文', 'en': 'English', 'ja': '日本語', 'ko': '한국어', 'vi': 'Tiếng Việt', 'th': 'ภาษาไทย' }; const langName = languageNames[outputLanguage] || '繁體中文'; const prompt = `你是一位專精於「5 Why 根因分析法」的資深顧問。請嚴格遵循以下五大執行要項進行分析: ## 五大執行要項 ### 1. 精準定義問題(描述現象,而非結論) - 第一步必須客觀描述「發生了什麼事」,而非直接跳入「我認為是甚麼問題」 - 具體化:包含人、事、時、地、物(5W1H) ### 2. 聚焦於「流程」與「系統」,而非「人」 - 若答案是「人為疏失」,請繼續追問:「為什麼系統允許這個疏失發生?」 - 原則:解決問題的機制,而非責備犯錯的人 ### 3. 基於「事實」與「現場」,拒絕「猜測」 - 每一個「為什麼」的回答,都必須是可查證的事實 - 若無法確認,應標註需要驗證的假設 ### 4. 邏輯的「雙向檢核」 - 順向檢查:若原因 X 發生,是否必然導致結果 Y? - 逆向檢查:若消除了原因 X,結果 Y 是否就不會發生? ### 5. 止於「可執行的對策」 - 根本原因必須能對應到一個「永久性對策」(不再發生) - 不僅是「暫時性對策」(如:重新訓練、加強宣導) --- ## 待分析內容 **Finding(發現的問題/現象):** ${finding} **工作內容背景:** ${jobContent} --- ## 輸出要求 請提供 **三個不同角度** 的 5 Why 分析,每個分析從不同的切入點出發(例如:流程面、系統面、管理面、設備面、環境面等)。 注意: - 5 Why 的目的不是「湊滿五個問題」,而是穿透表面症狀直達根本原因 - 若在第 3 或第 4 個 Why 就已找到真正的根本原因,可以停止 - 每個 Why 必須標註是「已驗證事實」還是「待驗證假設」 - 最終對策必須是「永久性對策」 ⚠️ 重要:請使用 **${langName}** 語言回覆所有內容。 請用以下 JSON 格式回覆(不要加任何 markdown 標記): { "problemRestatement": "根據 5W1H 重新描述的問題定義", "analyses": [ { "perspective": "分析角度(如:流程面)", "perspectiveIcon": "適合的 emoji", "whys": [ { "level": 1, "question": "為什麼...?", "answer": "因為...", "isVerified": true, "verificationNote": "已確認/需驗證:說明" } ], "rootCause": "根本原因(系統/流程層面)", "logicCheck": { "forward": "順向檢核:如果[原因]發生,則[結果]必然發生", "backward": "逆向檢核:如果消除[原因],則[結果]不會發生", "isValid": true }, "countermeasure": { "permanent": "永久性對策(系統性解決方案)", "actionItems": ["具體行動項目1", "具體行動項目2"], "avoidList": ["避免的暫時性做法(如:加強宣導)"] } } ] }`; // 呼叫 LLM API(支援 DeepSeek, Ollama 等) // DeepSeek 限制 max_tokens 最大為 8192,確保不超過 const effectiveMaxTokens = Math.min( Math.max(parseInt(llmConfig.max_tokens) || 4000, 4000), 8000 // DeepSeek 最大限制 ); const effectiveTemperature = parseFloat(llmConfig.temperature) || 0.7; console.log('Using max_tokens:', effectiveMaxTokens, 'temperature:', effectiveTemperature); const response = await axios.post( `${llmConfig.api_url}/v1/chat/completions`, { model: llmConfig.model_name, messages: [ { role: 'system', content: `你是 5 Why 根因分析專家。 重要規則: 1. 只回覆 JSON,不要任何其他文字 2. 不要使用 markdown 代碼塊 3. 直接以 { 開頭,以 } 結尾 4. 確保 JSON 格式正確完整 5. analyses 陣列必須包含 3 個分析角度 6. 每個角度的 whys 陣列包含 3-5 個 why` }, { role: 'user', content: prompt } ], temperature: effectiveTemperature, max_tokens: effectiveMaxTokens, stream: false }, { timeout: llmConfig.timeout, headers: { 'Content-Type': 'application/json', ...(llmConfig.api_key && { 'Authorization': `Bearer ${llmConfig.api_key}` }) } } ); // 處理回應 if (!response.data || !response.data.choices || !response.data.choices[0]) { throw new Error(`Invalid response from ${llmConfig.provider} API`); } const content = response.data.choices[0].message.content; console.log('LLM Response length:', content.length); console.log('LLM Response (first 500 chars):', content.substring(0, 500)); // 清理回應內容 let cleanContent = content .replace(/```json\s*/gi, '') .replace(/```\s*/g, '') .replace(/<\|[^|]*\|>/g, '') // 移除 <|channel|> 等特殊標記 .replace(/[\s\S]*?<\/think>/gi, '') // 移除思考過程 .replace(/^[\s\S]*?(?=\{)/m, '') // 移除 JSON 之前的所有內容 .trim(); // 找到 JSON 開始和結束位置 const jsonStart = cleanContent.indexOf('{'); const jsonEnd = cleanContent.lastIndexOf('}'); if (jsonStart === -1) { console.error('No JSON found in response:', cleanContent.substring(0, 500)); throw new Error('LLM 回應格式錯誤,無法找到 JSON'); } // 提取 JSON 部分 cleanContent = cleanContent.substring(jsonStart, jsonEnd + 1); console.log('Extracted JSON length:', cleanContent.length); // 嘗試解析 JSON let result; try { result = JSON.parse(cleanContent); } catch (parseError) { console.log('JSON parse failed:', parseError.message); console.log('Attempting to fix JSON...'); // 嘗試修復常見問題 let fixedContent = cleanContent // 修復未轉義的換行符 .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\t/g, '\\t') // 修復尾部逗號 .replace(/,(\s*[\}\]])/g, '$1') // 修復缺少逗號 .replace(/"\s*\n\s*"/g, '",\n"') .replace(/\}\s*\{/g, '},{') .replace(/\]\s*\[/g, '],['); try { result = JSON.parse(fixedContent); console.log('Fixed JSON parse successful'); } catch (fixError) { // 最後嘗試:用更激進的方式修復 console.log('Aggressive fix attempt...'); // 計算括號平衡 let braces = 0, brackets = 0, inStr = false, escape = false; for (const c of fixedContent) { if (escape) { escape = false; continue; } if (c === '\\') { escape = true; continue; } if (c === '"') { inStr = !inStr; continue; } if (!inStr) { if (c === '{') braces++; else if (c === '}') braces--; else if (c === '[') brackets++; else if (c === ']') brackets--; } } // 嘗試補上缺少的括號 fixedContent = fixedContent.replace(/,\s*$/, ''); while (brackets > 0) { fixedContent += ']'; brackets--; } while (braces > 0) { fixedContent += '}'; braces--; } try { result = JSON.parse(fixedContent); console.log('Aggressive fix successful'); } catch (finalError) { console.error('All JSON fix attempts failed'); console.error('Original content (first 1000):', cleanContent.substring(0, 1000)); throw new Error(`JSON 解析失敗。請重試或簡化輸入內容。`); } } } // 驗證結果結構 if (!result.problemRestatement || !result.analyses || !Array.isArray(result.analyses)) { throw new Error('LLM 回應缺少必要欄位 (problemRestatement 或 analyses)'); } // 計算處理時間 const processingTime = Math.floor((Date.now() - startTime) / 1000); // 儲存結果 await Analysis.saveResult(analysis.id, { problem_restatement: result.problemRestatement, analysis_result: result, processing_time: processingTime }); // 記錄稽核日誌 await AuditLog.logCreate( userId, 'analysis', analysis.id, { finding, outputLanguage }, req.ip, req.get('user-agent') ); res.json({ success: true, message: '分析完成', data: { id: analysis.id, problemRestatement: result.problemRestatement, analyses: result.analyses, processingTime } }); } catch (error) { console.error('Analysis error:', error); // 更新分析狀態為失敗 if (analysis && analysis.id) { await Analysis.updateStatus(analysis.id, 'failed', error.message); } res.status(500).json({ success: false, error: '分析失敗', message: error.message }); } })); /** * POST /api/analyze/translate * 翻譯分析結果 */ router.post('/translate', requireAuth, asyncHandler(async (req, res) => { const { analysisId, targetLanguage } = req.body; if (!analysisId || !targetLanguage) { return res.status(400).json({ success: false, error: '請提供分析 ID 和目標語言' }); } try { // 取得啟用的 LLM 配置 const llmConfig = await getActiveLLMConfig(); // 取得分析結果 const analysis = await Analysis.findById(analysisId); if (!analysis) { return res.status(404).json({ success: false, error: '找不到分析記錄' }); } const languageNames = { 'zh-TW': '繁體中文', 'zh-CN': '简体中文', 'en': 'English', 'ja': '日本語', 'ko': '한국어', 'vi': 'Tiếng Việt', 'th': 'ภาษาไทย' }; const langName = languageNames[targetLanguage] || '繁體中文'; const prompt = `請將以下 5 Why 分析結果翻譯成 **${langName}**。 原始內容: ${JSON.stringify(analysis.analysis_result, null, 2)} 請保持完全相同的 JSON 結構,只翻譯文字內容。 請用以下 JSON 格式回覆(不要加任何 markdown 標記): { "problemRestatement": "...", "analyses": [...] }`; const response = await axios.post( `${llmConfig.api_url}/v1/chat/completions`, { model: llmConfig.model_name, messages: [ { role: 'system', content: 'You are a professional translator. You always respond in valid JSON format without any markdown code blocks.' }, { role: 'user', content: prompt } ], temperature: 0.3, max_tokens: llmConfig.max_tokens, stream: false }, { timeout: llmConfig.timeout, headers: { 'Content-Type': 'application/json', ...(llmConfig.api_key && { 'Authorization': `Bearer ${llmConfig.api_key}` }) } } ); const content = response.data.choices[0].message.content; // 清理回應內容,移除 markdown 代碼塊標記和特殊標記 let cleanContent = content .replace(/```json\s*/gi, '') .replace(/```\s*/g, '') .replace(/<\|[^|]*\|>/g, '') .replace(/^[^{]*/, '') .trim(); // 嘗試提取 JSON 對象 const jsonMatch = cleanContent.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error('翻譯結果格式錯誤'); } cleanContent = jsonMatch[0]; const result = JSON.parse(cleanContent); res.json({ success: true, message: '翻譯完成', data: result }); } catch (error) { console.error('Translation error:', error); res.status(500).json({ success: false, error: '翻譯失敗', message: error.message }); } })); /** * GET /api/analyze/history * 取得分析歷史 */ router.get('/history', requireAuth, asyncHandler(async (req, res) => { const userId = req.session.userId; const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; const filters = { status: req.query.status, date_from: req.query.date_from, date_to: req.query.date_to, search: req.query.search }; const result = await Analysis.getByUserId(userId, page, limit, filters); res.json({ success: true, ...result }); })); /** * GET /api/analyze/:id * 取得特定分析詳細資料 */ router.get('/:id', requireAuth, asyncHandler(async (req, res) => { const analysisId = parseInt(req.params.id); const userId = req.session.userId; const userRole = req.session.userRole; const analysis = await Analysis.getFullAnalysis(analysisId); if (!analysis) { return res.status(404).json({ success: false, error: '找不到分析記錄' }); } // 檢查權限:只能查看自己的分析,除非是管理者 if (analysis.user_id !== userId && userRole !== 'admin' && userRole !== 'super_admin') { return res.status(403).json({ success: false, error: '無權存取此分析' }); } res.json({ success: true, data: analysis }); })); /** * DELETE /api/analyze/:id * 刪除分析記錄 */ router.delete('/:id', requireAuth, asyncHandler(async (req, res) => { const analysisId = parseInt(req.params.id); const userId = req.session.userId; const userRole = req.session.userRole; const analysis = await Analysis.findById(analysisId); if (!analysis) { return res.status(404).json({ success: false, error: '找不到分析記錄' }); } // 檢查權限 if (analysis.user_id !== userId && userRole !== 'admin' && userRole !== 'super_admin') { return res.status(403).json({ success: false, error: '無權刪除此分析' }); } await Analysis.delete(analysisId); // 記錄稽核日誌 await AuditLog.logDelete( userId, 'analysis', analysisId, { finding: analysis.finding }, req.ip, req.get('user-agent') ); res.json({ success: true, message: '已刪除分析記錄' }); })); export default router;