378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
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;
|
||
}
|