- Add 3 user roles: user, admin, super_admin - Restrict LLM config management to super_admin only - Restrict audit logs and statistics to super_admin only - Update AdminPage with role-based tab visibility - Add complete 5 Why prompt from 5why-analyzer.jsx - Add system documentation and authorization guide - Add ErrorModal component and seed test users script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
561 lines
16 KiB
JavaScript
561 lines
16 KiB
JavaScript
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(/<think>[\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;
|