453 lines
17 KiB
JavaScript
453 lines
17 KiB
JavaScript
const axios = require('axios');
|
||
const config = require('../../config/config');
|
||
const AiCallRecorder = require('./ai_call_recorder.js');
|
||
|
||
/**
|
||
* Qwen 2.5 大模型服务
|
||
* 集成阿里云 DashScope API,提供智能化的岗位筛选、聊天生成、简历分析等功能
|
||
*/
|
||
class aiService {
|
||
constructor() {
|
||
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
|
||
// 使用 DashScope 兼容 OpenAI 格式的接口
|
||
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
|
||
// Qwen 2.5 模型:qwen-turbo(快速)、qwen-plus(增强)、qwen-max(最强)
|
||
this.model = config.ai?.model || 'qwen-turbo';
|
||
this.maxRetries = 3;
|
||
}
|
||
|
||
/**
|
||
* 调用 Qwen 2.5 API
|
||
* @param {string} prompt - 提示词
|
||
* @param {object} options - 配置选项
|
||
* @returns {Promise<object>} API响应结果
|
||
*/
|
||
async callAPI(prompt, options = {}) {
|
||
const startTime = Date.now();
|
||
|
||
const requestData = {
|
||
model: this.model,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: options.systemPrompt || '你是一个专业的招聘顾问,擅长分析岗位信息、生成聊天内容和分析简历匹配度。'
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: prompt
|
||
}
|
||
],
|
||
temperature: options.temperature || 0.7,
|
||
max_tokens: options.maxTokens || 2000,
|
||
top_p: options.topP || 0.9
|
||
};
|
||
|
||
const requestContent = JSON.stringify(requestData.messages);
|
||
|
||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||
try {
|
||
const response = await axios.post(this.apiUrl, requestData, {
|
||
headers: {
|
||
'Authorization': `Bearer ${this.apiKey}`, // DashScope 使用 Bearer token 格式
|
||
'Content-Type': 'application/json'
|
||
},
|
||
timeout: 30000
|
||
});
|
||
|
||
const responseTime = Date.now() - startTime;
|
||
const responseContent = response.data.choices?.[0]?.message?.content || '';
|
||
const usage = response.data.usage || {};
|
||
|
||
// 记录AI调用(异步,不阻塞主流程)
|
||
this.recordAiCall({
|
||
user_id: options.user_id,
|
||
sn_code: options.sn_code,
|
||
service_type: options.service_type || 'completion',
|
||
model_name: this.model,
|
||
prompt_tokens: usage.prompt_tokens || 0,
|
||
completion_tokens: usage.completion_tokens || 0,
|
||
total_tokens: usage.total_tokens || 0,
|
||
request_content: requestContent,
|
||
response_content: responseContent,
|
||
cost_amount: this.calculateCost(usage.total_tokens || 0),
|
||
status: 'success',
|
||
response_time: responseTime,
|
||
api_provider: 'qwen',
|
||
business_type: options.business_type,
|
||
reference_id: options.reference_id
|
||
}).catch(err => {
|
||
console.warn('记录AI调用失败(不影响主流程):', err.message);
|
||
});
|
||
|
||
return {
|
||
data: response.data,
|
||
content: responseContent
|
||
};
|
||
} catch (error) {
|
||
const responseTime = Date.now() - startTime;
|
||
|
||
console.log(`Qwen 2.5 API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
|
||
|
||
// 记录失败的调用
|
||
if (attempt === this.maxRetries) {
|
||
this.recordAiCall({
|
||
user_id: options.user_id,
|
||
sn_code: options.sn_code,
|
||
service_type: options.service_type || 'completion',
|
||
model_name: this.model,
|
||
request_content: requestContent,
|
||
status: 'failed',
|
||
error_message: error.message,
|
||
response_time: responseTime,
|
||
api_provider: 'qwen',
|
||
business_type: options.business_type,
|
||
reference_id: options.reference_id
|
||
}).catch(err => {
|
||
console.warn('记录失败调用失败:', err.message);
|
||
});
|
||
|
||
throw new Error(error.message);
|
||
}
|
||
|
||
// 等待后重试
|
||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 记录AI调用
|
||
* @param {Object} params - 调用参数
|
||
* @returns {Promise}
|
||
*/
|
||
async recordAiCall(params) {
|
||
try {
|
||
await AiCallRecorder.record(params);
|
||
} catch (error) {
|
||
// 记录失败不应影响主流程
|
||
console.warn('AI调用记录失败:', error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 计算调用费用
|
||
* @param {Number} totalTokens - 总Token数
|
||
* @returns {Number} 费用(元)
|
||
*/
|
||
calculateCost(totalTokens) {
|
||
// 阿里云 Qwen 价格(元/1000 tokens)
|
||
// qwen-turbo: ¥0.003, qwen-plus: ¥0.004, qwen-max: ¥0.12
|
||
// 这里使用 qwen-turbo 的价格作为默认值
|
||
const pricePerThousand = 0.003;
|
||
return (totalTokens / 1000) * pricePerThousand;
|
||
}
|
||
|
||
/**
|
||
* 岗位智能筛选
|
||
* @param {object} jobInfo - 岗位信息
|
||
* @param {object} resumeInfo - 简历信息
|
||
* @returns {Promise<object>} 筛选结果
|
||
*/
|
||
async analyzeJob(jobInfo, resumeInfo) {
|
||
const prompt = `
|
||
请分析以下岗位信息,并给出详细的评估结果:
|
||
|
||
岗位信息:
|
||
- 公司名称:${jobInfo.companyName || '未知'}
|
||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||
- 薪资范围:${jobInfo.salary || '未知'}
|
||
- 工作地点:${jobInfo.location || '未知'}
|
||
- 岗位描述:${jobInfo.description || '未知'}
|
||
- 技能要求:${jobInfo.skills || '未知'}
|
||
|
||
简历信息:
|
||
- 技能标签:${resumeInfo.skills || '未知'}
|
||
- 工作经验:${resumeInfo.experience || '未知'}
|
||
- 教育背景:${resumeInfo.education || '未知'}
|
||
- 期望薪资:${resumeInfo.expectedSalary || '未知'}
|
||
|
||
请从以下维度进行分析:
|
||
1. 技能匹配度(0-100分)
|
||
2. 经验匹配度(0-100分)
|
||
3. 薪资合理性(0-100分)
|
||
4. 公司质量评估(0-100分)
|
||
5. 是否为外包岗位(是/否)
|
||
6. 综合推荐指数(0-100分)
|
||
7. 详细分析说明
|
||
8. 投递建议
|
||
|
||
请以JSON格式返回结果。
|
||
`;
|
||
|
||
const result = await this.callAPI(prompt, {
|
||
systemPrompt: '你是一个专业的招聘分析师,擅长评估岗位与简历的匹配度。请提供客观、专业的分析结果。',
|
||
temperature: 0.3,
|
||
business_type: 'job_analysis',
|
||
service_type: 'completion'
|
||
});
|
||
|
||
try {
|
||
// 尝试解析JSON响应
|
||
const analysis = JSON.parse(result.content);
|
||
return {
|
||
analysis: analysis
|
||
};
|
||
} catch (parseError) {
|
||
// 如果解析失败,返回原始内容
|
||
return {
|
||
analysis: {
|
||
content: result.content,
|
||
parseError: true
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成个性化聊天内容
|
||
* @param {object} jobInfo - 岗位信息
|
||
* @param {object} resumeInfo - 简历信息
|
||
* @param {string} chatType - 聊天类型 (greeting/interview/followup)
|
||
* @returns {Promise<object>} 聊天内容
|
||
*/
|
||
async generateChatContent(jobInfo, resumeInfo, chatType = 'greeting') {
|
||
const chatTypeMap = {
|
||
'greeting': '初次打招呼',
|
||
'interview': '面试邀约',
|
||
'followup': '跟进沟通'
|
||
};
|
||
|
||
const prompt = `
|
||
请为以下场景生成个性化的聊天内容:
|
||
|
||
聊天类型:${chatTypeMap[chatType] || chatType}
|
||
|
||
岗位信息:
|
||
- 公司名称:${jobInfo.companyName || '未知'}
|
||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||
- 技能要求:${jobInfo.skills || '未知'}
|
||
|
||
简历信息:
|
||
- 技能标签:${resumeInfo.skills || '未知'}
|
||
- 工作经验:${resumeInfo.experience || '未知'}
|
||
- 项目经验:${resumeInfo.projects || '未知'}
|
||
|
||
要求:
|
||
1. 内容要自然、专业、个性化
|
||
2. 突出简历与岗位的匹配点
|
||
3. 避免过于机械化的表达
|
||
4. 长度控制在100-200字
|
||
5. 体现求职者的诚意和热情
|
||
|
||
请直接返回聊天内容,不需要其他格式。
|
||
`;
|
||
|
||
const result = await this.callAPI(prompt, {
|
||
systemPrompt: '你是一个专业的招聘沟通专家,擅长生成自然、专业的求职聊天内容。',
|
||
temperature: 0.8,
|
||
business_type: 'chat_generation',
|
||
service_type: 'chat',
|
||
reference_id: jobInfo.jobId || jobInfo.id
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 根据 HR 消息判断回复意图并生成内容
|
||
* @param {object} params - { jobInfo, hrMessage, previousMessages? }
|
||
* @returns {Promise<{ action: 'text'|'send_resume'|'exchange_wechat'|'exchange_phone', reply_content: string }>}
|
||
*/
|
||
async replyIntentAndContent(params) {
|
||
const { jobInfo = {}, hrMessage = '', previousMessages = [] } = params;
|
||
const jobName = jobInfo.jobName || jobInfo.title || '未知职位';
|
||
const companyName = jobInfo.brandName || jobInfo.companyName || '未知公司';
|
||
|
||
const prompt = `
|
||
你正在处理 BOSS 直聘上的求职沟通。根据 HR 最新消息判断求职者应采取的回复动作。
|
||
|
||
【职位】${jobName}
|
||
【公司】${companyName}
|
||
|
||
【HR 最新消息】
|
||
${hrMessage || '(HR 未发文字,仅存在职位卡片等)'}
|
||
|
||
请严格按以下 JSON 格式返回(不要包含其他说明或换行):
|
||
{"action":"动作","reply_content":"内容"}
|
||
|
||
action 仅允许以下五种之一:
|
||
- no_reply:不需要回复(HR 明确表示暂不匹配、感谢关注、有合适机会再沟通、婉拒、不招了等,无需求职者再回复)
|
||
- text:仅文字回复(普通聊天、打招呼、问是否考虑机会等)
|
||
- send_resume:发简历(HR 要求发简历、看简历、投递等)
|
||
- exchange_wechat:换微信(HR 要求加微信、留微信、发微信等)
|
||
- exchange_phone:换电话(HR 要求留电话、发电话、联系方式等)
|
||
|
||
规则:
|
||
1. 若 HR 明确表示暂不匹配、感谢关注、有合适机会再沟通、与岗位不够匹配、婉拒、不考虑、不招了、已招到 等 → action 为 no_reply,reply_content 留空。
|
||
2. 若 HR 明确要求发简历/投递/看简历 → action 为 send_resume,reply_content 可为简短附言或空。
|
||
3. 若 HR 明确要求加微信/留微信/发微信 → action 为 exchange_wechat,reply_content 可为简短附言或空。
|
||
4. 若 HR 明确要求留电话/发电话/联系方式 → action 为 exchange_phone,reply_content 可为简短附言或空。
|
||
5. 若仅为普通聊天、打招呼 → action 为 text,reply_content 为一句自然回复(50字以内)。
|
||
6. reply_content 必须为字符串,不要换行。
|
||
`.trim();
|
||
|
||
const result = await this.callAPI(prompt, {
|
||
systemPrompt: '你是求职沟通助手。根据 HR 消息判断动作:no_reply(不需要回复)、text(仅文字)、send_resume(发简历)、exchange_wechat(换微信)、exchange_phone(换电话)。HR 婉拒/暂不匹配/感谢关注时用 no_reply。输出 JSON:{"action":"上述五选一","reply_content":"..."}。只返回合法 JSON。',
|
||
temperature: 0.3,
|
||
maxTokens: 500,
|
||
business_type: 'chat_reply_intent',
|
||
service_type: 'completion'
|
||
});
|
||
|
||
const raw = (result && result.content) ? result.content.trim() : '';
|
||
const allowed = ['no_reply', 'text', 'send_resume', 'exchange_wechat', 'exchange_phone'];
|
||
try {
|
||
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
||
const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : {};
|
||
const action = allowed.includes(parsed.action) ? parsed.action : 'text';
|
||
const reply_content = typeof parsed.reply_content === 'string' ? parsed.reply_content.trim() : '';
|
||
return { action, reply_content };
|
||
} catch (e) {
|
||
return { action: 'text', reply_content: raw || '收到,谢谢您。' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 分析简历要素
|
||
* @param {string} resumeText - 简历文本内容
|
||
* @returns {Promise<object>} 简历分析结果
|
||
*/
|
||
async analyzeResume(resumeText) {
|
||
const prompt = `
|
||
请分析以下简历内容,并返回 JSON 格式的分析结果:
|
||
|
||
简历内容:
|
||
${resumeText}
|
||
|
||
请按以下格式返回 JSON 结果:
|
||
{
|
||
"skillTags": ["技能1", "技能2", "技能3"], // 技能标签数组(编程语言、框架、工具等)
|
||
"strengths": "核心优势描述", // 简历的优势和亮点
|
||
"weaknesses": "不足之处描述", // 简历的不足或需要改进的地方
|
||
"careerSuggestion": "职业发展建议", // 针对该简历的职业发展方向和建议
|
||
"competitiveness": 75 // 竞争力评分(0-100的整数),综合考虑工作年限、技能、经验等因素
|
||
}
|
||
|
||
要求:
|
||
1. skillTags 必须是字符串数组
|
||
2. strengths、weaknesses、careerSuggestion 是字符串描述
|
||
3. competitiveness 必须是 0-100 之间的整数
|
||
4. 所有字段都必须返回,如果没有相关信息,使用空数组或空字符串
|
||
`;
|
||
|
||
const result = await this.callAPI(prompt, {
|
||
systemPrompt: '你是一个专业的简历分析师,擅长分析简历的核心要素、优势劣势、竞争力评分和职业发展建议。请以 JSON 格式返回分析结果,确保格式正确。',
|
||
temperature: 0.3,
|
||
maxTokens: 1500,
|
||
business_type: 'resume_analysis',
|
||
service_type: 'completion'
|
||
});
|
||
|
||
try {
|
||
// 尝试从返回内容中提取 JSON
|
||
let content = result.content.trim();
|
||
|
||
// 如果返回内容被代码块包裹,提取其中的 JSON
|
||
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || content.match(/(\{[\s\S]*\})/);
|
||
if (jsonMatch) {
|
||
content = jsonMatch[1];
|
||
}
|
||
|
||
const analysis = JSON.parse(content);
|
||
return {
|
||
analysis: analysis
|
||
};
|
||
} catch (parseError) {
|
||
console.error(`[AI服务] 简历分析结果解析失败:`, parseError);
|
||
console.error(`[AI服务] 原始内容:`, result.content);
|
||
return {
|
||
analysis: {
|
||
content: result.content,
|
||
parseError: true
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成面试邀约内容
|
||
* @param {object} jobInfo - 岗位信息
|
||
* @param {object} chatHistory - 聊天历史
|
||
* @returns {Promise<object>} 面试邀约内容
|
||
*/
|
||
async generateInterviewInvitation(jobInfo, chatHistory) {
|
||
const prompt = `
|
||
请基于以下信息生成面试邀约内容:
|
||
|
||
岗位信息:
|
||
- 公司名称:${jobInfo.companyName || '未知'}
|
||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||
- 工作地点:${jobInfo.location || '未知'}
|
||
|
||
聊天历史:
|
||
${chatHistory || '无'}
|
||
|
||
要求:
|
||
1. 表达面试邀约的诚意
|
||
2. 提供灵活的时间选择
|
||
3. 说明面试形式和地点
|
||
4. 体现对候选人的重视
|
||
5. 语言自然、专业
|
||
|
||
请直接返回面试邀约内容。
|
||
`;
|
||
|
||
const result = await this.callAPI(prompt, {
|
||
systemPrompt: '你是一个专业的HR,擅长生成面试邀约内容。',
|
||
temperature: 0.6,
|
||
business_type: 'interview_invitation',
|
||
service_type: 'chat'
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 识别外包岗位
|
||
* @param {object} jobInfo - 岗位信息
|
||
* @returns {Promise<object>} 外包识别结果
|
||
*/
|
||
async identifyOutsourcingJob(jobInfo) {
|
||
const prompt = `
|
||
请分析以下岗位信息,判断是否为外包岗位:
|
||
|
||
岗位信息:
|
||
- 公司名称:${jobInfo.companyName || '未知'}
|
||
- 职位名称:${jobInfo.jobTitle || '未知'}
|
||
- 岗位描述:${jobInfo.description || '未知'}
|
||
- 技能要求:${jobInfo.skills || '未知'}
|
||
- 工作地点:${jobInfo.location || '未知'}
|
||
|
||
外包岗位特征:
|
||
1. 公司名称包含"外包"、"派遣"、"人力"等关键词
|
||
2. 岗位描述提到"项目外包"、"驻场开发"等
|
||
3. 技能要求过于宽泛或具体
|
||
4. 工作地点频繁变动
|
||
5. 薪资结构不明确
|
||
|
||
请判断是否为外包岗位,并给出详细分析。
|
||
`;
|
||
|
||
const result = await this.callAPI(prompt, {
|
||
systemPrompt: '你是一个专业的岗位分析师,擅长识别外包岗位的特征。',
|
||
temperature: 0.3,
|
||
business_type: 'outsourcing_detection',
|
||
service_type: 'completion'
|
||
});
|
||
|
||
return result;
|
||
}
|
||
}
|
||
|
||
module.exports = new aiService();
|