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 { 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 { // 构建员工绩效内容文本(作为 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 { // 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( '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; }