Files
performance-evaluation-system/backend/src/services/AIEvaluationService.ts
2026-04-13 10:18:44 +08:00

378 lines
14 KiB
TypeScript
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 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://cloud.fastgpt.cn/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;
}