first commit

This commit is contained in:
2026-04-11 11:51:54 +08:00
commit b12a84e388
99 changed files with 19620 additions and 0 deletions

View 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;
}