first commit
This commit is contained in:
377
backend/src/services/AIEvaluationService.ts
Normal file
377
backend/src/services/AIEvaluationService.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import axios from 'axios';
|
||||
import * as PerformanceDAO from '../dao/PerformanceDAO';
|
||||
import * as AIResultDAO from '../dao/AIResultDAO';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIScoreData {
|
||||
aiScoreDetail: AIResultDAO.AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
}
|
||||
|
||||
// ─── Prompt Builder ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the prompt sent to FastGPT.
|
||||
* Includes employee position, month, performance items, attendance, and scoring rules.
|
||||
* Requirements: 13.2
|
||||
*/
|
||||
export function buildPrompt(
|
||||
performance: PerformanceDAO.PerformanceRow,
|
||||
items: PerformanceDAO.PerfItemRow[],
|
||||
attendance: PerformanceDAO.AttendanceRow | null,
|
||||
position: string
|
||||
): string {
|
||||
const itemsText = items
|
||||
.map(
|
||||
(item, idx) =>
|
||||
`${idx + 1}. 【${item.item_name}】(${item.item_category === 'business' ? '业务素质' : '综合素质'},权重${item.weight}分)\n` +
|
||||
` 员工填写内容:${item.user_content ?? '(未填写)'}\n` +
|
||||
` 员工自评分:${item.self_score ?? '(未填写)'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const attendanceText = attendance
|
||||
? `事假${attendance.leave_days}天,迟到${attendance.late_times}次,缺卡${attendance.lack_card_times}次,旷工${attendance.absent_days}天`
|
||||
: '无考勤数据';
|
||||
|
||||
return `你是一名专业的HR绩效评估专家,请根据以下员工绩效填报内容进行客观评分。
|
||||
|
||||
## 员工信息
|
||||
- 岗位:${position}
|
||||
- 考核月份:${performance.month}
|
||||
|
||||
## 考核项目及填报内容
|
||||
${itemsText}
|
||||
|
||||
## 考勤情况
|
||||
${attendanceText}
|
||||
|
||||
## 工作汇总
|
||||
${performance.work_summary ?? '(未填写)'}
|
||||
|
||||
## 评分规则
|
||||
- 业务素质考评占总分70%,综合素质考评占总分30%
|
||||
- 每个考核项按0-100分评分,最终加权计算总分
|
||||
- 请根据员工填写内容的质量、完整性和实际工作表现进行评分
|
||||
|
||||
## 输出要求
|
||||
请严格按照以下JSON格式输出,不要包含任何其他内容:
|
||||
{
|
||||
"ai_score_detail": [
|
||||
{
|
||||
"itemName": "考核项名称",
|
||||
"weight": 权重分值,
|
||||
"aiScore": 评分(0-100),
|
||||
"scoreExplanation": "评分说明"
|
||||
}
|
||||
],
|
||||
"ai_total_score": 加权总分(0-100),
|
||||
"ai_problems": ["问题1", "问题2", "问题3"],
|
||||
"ai_suggestions": ["建议1", "建议2", "建议3"]
|
||||
}
|
||||
|
||||
注意:
|
||||
- ai_problems 和 ai_suggestions 各需要3到5条
|
||||
- ai_total_score 为各项加权分数之和,业务素质项权重之和为70,综合素质项权重之和为30
|
||||
- 所有字段必须存在且格式正确`;
|
||||
}
|
||||
|
||||
// ─── Response Parser ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse and validate the AI JSON response.
|
||||
* Extracts ai_score_detail, ai_total_score, ai_problems, ai_suggestions.
|
||||
* Throws on invalid format, recording the raw response.
|
||||
* Requirements: 3.2, 3.3, 3.4, 13.3, 13.4
|
||||
*/
|
||||
export function parseAIResponse(rawResponse: string): AIScoreData {
|
||||
let parsed: any;
|
||||
|
||||
// Extract JSON from the response (model may wrap it in markdown code blocks)
|
||||
const jsonMatch = rawResponse.match(/```(?:json)?\s*([\s\S]*?)```/) ||
|
||||
rawResponse.match(/(\{[\s\S]*\})/);
|
||||
|
||||
const jsonStr = jsonMatch ? jsonMatch[1].trim() : rawResponse.trim();
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
throw new Error(`AI响应JSON解析失败,原始响应:${rawResponse}`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!Array.isArray(parsed.ai_score_detail)) {
|
||||
throw new Error(`AI响应缺少 ai_score_detail 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (typeof parsed.ai_total_score !== 'number') {
|
||||
throw new Error(`AI响应缺少有效的 ai_total_score 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (!Array.isArray(parsed.ai_problems) || parsed.ai_problems.length < 1) {
|
||||
throw new Error(`AI响应缺少 ai_problems 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (!Array.isArray(parsed.ai_suggestions) || parsed.ai_suggestions.length < 1) {
|
||||
throw new Error(`AI响应缺少 ai_suggestions 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
|
||||
// Validate and map score detail items
|
||||
const aiScoreDetail: AIResultDAO.AIScoreItem[] = parsed.ai_score_detail.map((item: any, idx: number) => {
|
||||
if (typeof item.itemName !== 'string' || typeof item.weight !== 'number' ||
|
||||
typeof item.aiScore !== 'number' || typeof item.scoreExplanation !== 'string') {
|
||||
throw new Error(`AI响应 ai_score_detail[${idx}] 格式无效,原始响应:${rawResponse}`);
|
||||
}
|
||||
return {
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
aiScoreDetail,
|
||||
aiTotalScore: parsed.ai_total_score,
|
||||
aiProblems: parsed.ai_problems as string[],
|
||||
aiSuggestions: parsed.ai_suggestions as string[],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── FastGPT API Call ─────────────────────────────────────────────────────────
|
||||
|
||||
const FASTGPT_API_URL = process.env.FASTGPT_API_URL || 'https://api.fastgpt.in/api/v1/chat/completions';
|
||||
const FASTGPT_API_KEY = process.env.FASTGPT_API_KEY || '';
|
||||
const AI_TIMEOUT_MS = 60_000; // 60秒超时
|
||||
|
||||
// 读取系统代理配置(支持 HTTP_PROXY / HTTPS_PROXY 环境变量)
|
||||
function getProxyConfig() {
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.https_proxy || process.env.http_proxy;
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port, 10),
|
||||
protocol: url.protocol.replace(':', '') as 'http' | 'https',
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call FastGPT workflow API with structured variables.
|
||||
* FastGPT workflow expects: variables (position, month) + userChatInput (绩效内容)
|
||||
*/
|
||||
async function callFastGPT(
|
||||
position: string,
|
||||
month: string,
|
||||
contentText: string
|
||||
): Promise<AIScoreData> {
|
||||
console.log(`[AI] 调用 FastGPT API: ${FASTGPT_API_URL}`);
|
||||
|
||||
const proxyConfig = getProxyConfig();
|
||||
if (proxyConfig) {
|
||||
console.log(`[AI] 使用代理: ${proxyConfig.host}:${proxyConfig.port}`);
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
FASTGPT_API_URL,
|
||||
{
|
||||
messages: [{ role: 'user', content: contentText }],
|
||||
variables: {
|
||||
zslh34AG: position, // 员工岗位
|
||||
month: month, // 考核月份
|
||||
},
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${FASTGPT_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: AI_TIMEOUT_MS,
|
||||
proxy: proxyConfig,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[AI] FastGPT 响应状态: ${response.status}`);
|
||||
|
||||
// FastGPT workflow 返回的最终结果在 choices[0].message.content 里
|
||||
// 内容是 system_rawResponse 对象(JSON字符串或对象)
|
||||
const content = response.data?.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`FastGPT API 返回内容为空,响应: ${JSON.stringify(response.data).substring(0, 300)}`);
|
||||
}
|
||||
|
||||
console.log(`[AI] FastGPT 原始返回:`, typeof content === 'string' ? content.substring(0, 300) : JSON.stringify(content).substring(0, 300));
|
||||
|
||||
// content 可能是带 markdown 代码块的字符串,或纯 JSON 字符串,或对象
|
||||
let parsed: any;
|
||||
if (typeof content === 'object') {
|
||||
parsed = content;
|
||||
} else {
|
||||
// 先尝试去掉 markdown 代码块 ```json ... ```
|
||||
let jsonStr = content.trim();
|
||||
const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (codeBlockMatch) {
|
||||
jsonStr = codeBlockMatch[1].trim();
|
||||
} else {
|
||||
// 直接提取第一个完整 JSON 对象
|
||||
const objMatch = jsonStr.match(/\{[\s\S]*\}/);
|
||||
if (objMatch) {
|
||||
jsonStr = objMatch[0];
|
||||
}
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch (e: any) {
|
||||
throw new Error(`FastGPT 返回内容无法解析为 JSON: ${e.message},内容片段: ${jsonStr.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验必要字段
|
||||
if (!Array.isArray(parsed.ai_score_detail)) {
|
||||
throw new Error(`响应缺少 ai_score_detail,内容: ${JSON.stringify(parsed).substring(0, 300)}`);
|
||||
}
|
||||
if (typeof parsed.ai_total_score !== 'number') {
|
||||
throw new Error(`响应缺少有效的 ai_total_score,内容: ${JSON.stringify(parsed).substring(0, 300)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
aiScoreDetail: parsed.ai_score_detail.map((item: any) => ({
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
})),
|
||||
aiTotalScore: parsed.ai_total_score,
|
||||
aiProblems: Array.isArray(parsed.ai_problems) ? parsed.ai_problems : [],
|
||||
aiSuggestions: Array.isArray(parsed.ai_suggestions) ? parsed.ai_suggestions : [],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Core Evaluation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform a single AI evaluation attempt for the given performance record.
|
||||
*/
|
||||
async function evaluateOnce(
|
||||
perfId: number,
|
||||
performance: PerformanceDAO.PerformanceRow,
|
||||
items: PerformanceDAO.PerfItemRow[],
|
||||
attendance: PerformanceDAO.AttendanceRow | null,
|
||||
position: string
|
||||
): Promise<AIResultDAO.AIResult> {
|
||||
// 构建员工绩效内容文本(作为 userChatInput 传入 workflow)
|
||||
const itemsText = items
|
||||
.map(
|
||||
(item, idx) =>
|
||||
`${idx + 1}. 【${item.item_name}】(权重${item.weight}分)\n` +
|
||||
` 员工填写:${item.user_content ?? '(未填写)'}\n` +
|
||||
` 自评分:${item.self_score ?? '(未填写)'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const attendanceText = attendance
|
||||
? `事假${attendance.leave_days}天,迟到${attendance.late_times}次,缺卡${attendance.lack_card_times}次,旷工${attendance.absent_days}天`
|
||||
: '无考勤数据';
|
||||
|
||||
const contentText = `考勤情况:${attendanceText}\n\n工作汇总:${performance.work_summary ?? '(未填写)'}\n\n考核项目:\n${itemsText}`;
|
||||
|
||||
const scoreData = await callFastGPT(position, performance.month, contentText);
|
||||
|
||||
const aiId = await AIResultDAO.save({
|
||||
perfId,
|
||||
aiScoreDetail: scoreData.aiScoreDetail,
|
||||
aiTotalScore: scoreData.aiTotalScore,
|
||||
aiProblems: scoreData.aiProblems,
|
||||
aiSuggestions: scoreData.aiSuggestions,
|
||||
apiResponse: JSON.stringify(scoreData),
|
||||
});
|
||||
|
||||
return {
|
||||
aiId,
|
||||
perfId,
|
||||
aiScoreDetail: scoreData.aiScoreDetail,
|
||||
aiTotalScore: scoreData.aiTotalScore,
|
||||
aiProblems: scoreData.aiProblems,
|
||||
aiSuggestions: scoreData.aiSuggestions,
|
||||
createTime: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Retry + Degradation ──────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Evaluate performance with up to MAX_RETRIES attempts.
|
||||
* On all failures, logs the error and updates the performance status to 'ai_failed' (degradation).
|
||||
* Requirements: 3.5
|
||||
*/
|
||||
export async function evaluatePerformance(perfId: number): Promise<AIResultDAO.AIResult> {
|
||||
// Load full performance detail
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(perfId);
|
||||
if (!detail) {
|
||||
throw new Error(`绩效记录不存在: perfId=${perfId}`);
|
||||
}
|
||||
|
||||
// Load employee position from user table
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [userRows] = await pool.query<any[]>(
|
||||
'SELECT position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
const position: string = userRows[0]?.position ?? '未知岗位';
|
||||
|
||||
let lastError: Error = new Error('未知错误');
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await evaluateOnce(
|
||||
perfId,
|
||||
detail.performance,
|
||||
detail.items,
|
||||
detail.attendance,
|
||||
position
|
||||
);
|
||||
|
||||
// Update ai_score on performance_month
|
||||
await pool.query(
|
||||
'UPDATE performance_month SET ai_score = ? WHERE perf_id = ?',
|
||||
[result.aiTotalScore, perfId]
|
||||
);
|
||||
|
||||
// 回写 AI 评分到 perf_item 各考核项
|
||||
for (const scoreItem of result.aiScoreDetail) {
|
||||
await pool.query(
|
||||
`UPDATE perf_item SET ai_score = ?, ai_explanation = ?
|
||||
WHERE perf_id = ? AND item_name = ?`,
|
||||
[scoreItem.aiScore, scoreItem.scoreExplanation, perfId, scoreItem.itemName]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[AI] 评分完成 perfId=${perfId},总分=${result.aiTotalScore}(第${attempt}次尝试)`);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
console.error(`[AI] 第${attempt}次评分失败 perfId=${perfId}:`, err.message);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Brief delay before retry (exponential backoff: 1s, 2s)
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted — degradation: log error, notify admin via console
|
||||
console.error(`[AI] 评分最终失败 perfId=${perfId},已重试${MAX_RETRIES}次。错误:${lastError.message}`);
|
||||
|
||||
// Note: We don't log to operation_log here because there's no user_id context
|
||||
// Admin can check console logs for AI evaluation failures
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
Reference in New Issue
Block a user