Files
5why-analyzer/routes/analyze.js
donald 957003bc7c feat: Add multi-LLM provider support with DeepSeek integration
Major Features:
-  Multi-LLM provider support (DeepSeek, Ollama, OpenAI, Custom)
- 🤖 Admin panel LLM configuration management UI
- 🔄 Dynamic provider switching without restart
- 🧪 Built-in API connection testing
- 🔒 Secure API key management

Backend Changes:
- Add routes/llmConfig.js: Complete LLM config CRUD API
- Update routes/analyze.js: Use database LLM configuration
- Update server.js: Add LLM config routes
- Add scripts/add-deepseek-config.js: DeepSeek setup script

Frontend Changes:
- Update src/pages/AdminPage.jsx: Add LLM Config tab + modal
- Update src/services/api.js: Add LLM config API methods
- Provider presets for DeepSeek, Ollama, OpenAI
- Test connection feature in config modal

Configuration:
- Update .env.example: Add DeepSeek API configuration
- Update package.json: Add llm:add-deepseek script

Documentation:
- Add docs/LLM_CONFIGURATION_GUIDE.md: Complete guide
- Add DEEPSEEK_INTEGRATION.md: Integration summary
- Quick setup instructions for DeepSeek

API Endpoints:
- GET /api/llm-config: List all configurations
- GET /api/llm-config/active: Get active configuration
- POST /api/llm-config: Create configuration
- PUT /api/llm-config/🆔 Update configuration
- PUT /api/llm-config/:id/activate: Activate configuration
- DELETE /api/llm-config/🆔 Delete configuration
- POST /api/llm-config/test: Test API connection

Database:
- Uses existing llm_configs table
- Only one config active at a time
- Fallback to Ollama if no database config

Security:
- Admin-only access to LLM configuration
- API keys never returned in GET requests
- Audit logging for all config changes
- Cannot delete active configuration

DeepSeek Model:
- Model: deepseek-chat
- High-quality 5 Why analysis
- Excellent Chinese language support
- Cost-effective pricing

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 00:33:10 +08:00

444 lines
12 KiB
JavaScript
Raw 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_name, api_endpoint, api_key, model_name, temperature, max_tokens, timeout_seconds
FROM llm_configs
WHERE is_active = 1
LIMIT 1`
);
// 如果沒有資料庫配置,使用環境變數的 Ollama 配置
if (!config) {
return {
provider_name: 'Ollama',
api_endpoint: ollamaConfig.apiUrl,
api_key: null,
model_name: ollamaConfig.model,
temperature: ollamaConfig.temperature,
max_tokens: ollamaConfig.maxTokens,
timeout_seconds: ollamaConfig.timeout / 1000
};
}
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();
try {
// 取得啟用的 LLM 配置
const llmConfig = await getActiveLLMConfig();
// 建立分析記錄
const 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 就已找到真正的根本原因,可以停止(設為 null
- 每個 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 等)
const response = await axios.post(
`${llmConfig.api_endpoint}/v1/chat/completions`,
{
model: llmConfig.model_name,
messages: [
{
role: 'system',
content: 'You are an expert consultant specializing in 5 Why root cause analysis. You always respond in valid JSON format without any markdown code blocks.'
},
{
role: 'user',
content: prompt
}
],
temperature: llmConfig.temperature,
max_tokens: llmConfig.max_tokens,
stream: false
},
{
timeout: llmConfig.timeout_seconds * 1000,
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_name} API`);
}
const content = response.data.choices[0].message.content;
const cleanContent = content.replace(/```json|```/g, '').trim();
const result = JSON.parse(cleanContent);
// 計算處理時間
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_endpoint}/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_seconds * 1000,
headers: {
'Content-Type': 'application/json',
...(llmConfig.api_key && { 'Authorization': `Bearer ${llmConfig.api_key}` })
}
}
);
const content = response.data.choices[0].message.content;
const cleanContent = content.replace(/```json|```/g, '').trim();
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;