Files
5why-analyzer/routes/analyze.js
donald 66cdcacce9 feat: Implement role-based access control (RBAC) with 3-tier authorization
- 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>
2025-12-08 19:29:28 +08:00

561 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;