This commit is contained in:
张成
2026-02-28 17:38:45 +08:00
parent 5ec4e7f440
commit a40219c7e4
5 changed files with 802 additions and 44 deletions

View File

@@ -1,4 +1,5 @@
const ai_service = require('../../../services/ai_service');
const db = require('../../dbProxy');
/**
* 聊天管理模块
@@ -63,13 +64,13 @@ class ChatManager {
// 存储数据库
console.log(`[聊天管理] 成功获取聊天列表,共 ${parsed.friendList.length} 个联系人`);
return parsed;
}
/**
* 解析 get_chat_detail 设备端返回格式
* 格式: { type, code, message, data: { success, apiData: { response: { zpData } }, getBossData: { response: { zpData } } } }
@@ -195,21 +196,63 @@ class ChatManager {
return response;
}
/** 是否为系统/模板消息竞争者PK、拒绝模板、系统卡片等不参与回复判断 */
_isSystemMessage(msg) {
const body = msg.body || {};
if (msg.bizType === 317 || msg.bizType === 21050003) return true;
if (msg.type === 4) return true;
if (body.type === 16) return true;
return false;
}
/** 过滤出 HR 发的、非系统、可回复的消息列表(已排除自己发的) */
_filterHrReplyableMessages(messages, geek_uid) {
if (!geek_uid || !Array.isArray(messages)) return [];
let list = messages.filter(msg => {
if (!msg.from || msg.from.uid === geek_uid) return false;
if (this._isSystemMessage(msg)) return false;
return true;
});
return list
}
/** AI 回复后写入 chat_reply_intent_logoptions 含 sn_code/platform/friendId/encryptFriendId 时落库 */
_saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, replied, reason) {
if (!options || options.sn_code == null) return;
try {
const model = db.getModel('chat_reply_intent_log');
model.create({
sn_code: options.sn_code || '',
platform: options.platform || 'boss',
friendId: options.friendId ?? null,
encrypt_friend_id: options.encryptFriendId || '',
hr_message_text: hr_message_text || null,
action: action || '',
reply_content: reply_content || null,
replied: !!replied,
reason: reason || null,
job_name: (jobInfo && (jobInfo.jobName || jobInfo.title)) || null,
create_time: new Date()
}).catch(e => console.warn('[聊天管理] 写入 chat_reply_intent_log 失败:', e.message));
} catch (e) {
console.warn('[聊天管理] 写入 chat_reply_intent_log 失败:', e.message);
}
}
/**
* 根据沟通详情get_chat_detail 的解析结果)判断是否需回复,并用 AI 生成回复文案
* 供任务层在「获取详情」指令执行后调用,不包含发送消息(由任务层再下发 send_chat_message 指令)
*
* 根据沟通详情判断是否需回复,并用 AI 判断意图(文字回复 / 发简历)及生成内容
* @param {object} detail - 沟通详情,含 variant、messages、job 等
* @returns {Promise<object>} { replied: true, reply_content, hr_message_text } | { replied: false, reason }
* @param {object} options - 可选,落库用 { sn_code, platform, friendId, encryptFriendId }
* @returns {Promise<object>} { replied, action?, reply_content?, hr_message_text?, reason? }
*/
async getReplyContentFromDetail(detail) {
async getReplyContentFromDetail(detail, options) {
if (!detail || detail.variant !== 'messages' || !Array.isArray(detail.messages) || detail.messages.length === 0) {
return { replied: false, reason: '无可用消息' };
}
const messages = detail.messages;
// 推断 HR 与 求职者 uid
let hr_uid = null;
let geek_uid = null;
@@ -217,58 +260,62 @@ class ChatManager {
const body = msg.body || {};
const jobDesc = body.jobDesc || body.job_desc || null;
if (jobDesc) {
if (jobDesc.boss && jobDesc.boss.uid && !hr_uid) {
hr_uid = jobDesc.boss.uid;
}
if (jobDesc.geek && jobDesc.geek.uid && !geek_uid) {
geek_uid = jobDesc.geek.uid;
}
if (jobDesc.boss && jobDesc.boss.uid && !hr_uid) hr_uid = jobDesc.boss.uid;
if (jobDesc.geek && jobDesc.geek.uid && !geek_uid) geek_uid = jobDesc.geek.uid;
}
if (hr_uid && geek_uid) break;
}
const last = messages[messages.length - 1];
// 兜底:还没有 hr_uid 时,用最后一条的 from/to 做简单推断
if ((!hr_uid || !geek_uid) && last && last.from && last.to) {
hr_uid = hr_uid || last.from.uid;
geek_uid = geek_uid || last.to.uid;
const lastRaw = messages[messages.length - 1];
if (lastRaw && lastRaw.from && lastRaw.to) {
hr_uid = hr_uid || lastRaw.from.uid;
geek_uid = geek_uid || lastRaw.to.uid;
}
if (!last || !last.from || !hr_uid || last.from.uid !== hr_uid) {
// 最后一条不是 HR 发的,不自动回复
return { replied: false, reason: '最后一条不是HR消息' };
const jobInfo = detail.job || {};
const hrList = this._filterHrReplyableMessages(messages, geek_uid);
if (hrList.length === 0) {
this._saveReplyIntentLog(options, '', jobInfo, '', '', false, '无HR可回复消息已过滤系统与己方');
return { replied: false, reason: '无HR可回复消息已过滤系统与己方' };
}
const last = hrList[hrList.length - 1];
if (!last.from || last.from.uid !== hr_uid) {
this._saveReplyIntentLog(options, '', jobInfo, '', '', false, '最后一条可回复消息不是HR');
return { replied: false, reason: '最后一条可回复消息不是HR' };
}
// 取 HR 文本内容(普通文本优先)
const body = last.body || {};
const hr_message_text =
(typeof body.text === 'string' && body.text) ||
(typeof last.pushText === 'string' && last.pushText) ||
'';
if (!hr_message_text || !hr_message_text.trim()) {
return { replied: false, reason: 'HR消息没有可用文本' };
}
// 3. 调用阿里云 Qwen 生成回复文案(已在 config 中切换为 qwen-plus
const jobInfo = detail.job || {};
const reply_content = await ai_service.generateChatContent({
const { action, reply_content } = await ai_service.replyIntentAndContent({
jobInfo,
resumeInfo: null,
chatType: 'reply',
hrMessage: hr_message_text,
previousMessages: [] // 如需上下文,这里可以把 detail.messages 映射进去
previousMessages: hrList.slice(-5).map(m => (m.body && m.body.text) || m.pushText || '')
});
if (!reply_content || !reply_content.trim()) {
return { replied: false, reason: 'AI 未生成有效回复' };
if (action === 'no_reply') {
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, false, 'HR表示暂不匹配/无需回复');
return { replied: false, reason: 'HR表示暂不匹配/无需回复' };
}
const needContent = action === 'text';
if (needContent && (!reply_content || !reply_content.trim())) {
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, false, 'AI 未生成有效回复文案');
return { replied: false, reason: 'AI 未生成有效回复文案' };
}
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, true, null);
return {
replied: true,
reply_content,
action: action || 'text',
reply_content: reply_content || '',
hr_message_text
};
}
@@ -287,12 +334,25 @@ class ChatManager {
if (!friendId) throw new Error('friendId 不能为空');
const parsed = await this.get_chat_detail(sn_code, mqttClient, { platform, ...detailParams });
const decision = await this.getReplyContentFromDetail(parsed);
const decision = await this.getReplyContentFromDetail(parsed, {
sn_code,
platform,
friendId,
encryptFriendId: detailParams.encryptFriendId || ''
});
if (!decision.replied) return decision;
const action = decision.action || 'text';
const content = decision.reply_content || '';
const actionMessages = {
send_resume: [{ type: 'send_resume', content }],
exchange_wechat: [{ type: 'exchange_wechat', content }],
exchange_phone: [{ type: 'exchange_phone', content }]
};
const messages = actionMessages[action] || [{ type: 'text', content }];
await this.send_chat_message(sn_code, mqttClient, {
friendId,
messages: [{ type: 'text', content: decision.reply_content }],
messages,
chatType: 'reply',
platform
});

View File

@@ -189,16 +189,30 @@ class ChatHandler extends BaseHandler {
await this._saveChatMessagesToDb(parsed, friend, sn_code, platform_type);
const decision = await chatManager.getReplyContentFromDetail(parsed || {});
const decision = await chatManager.getReplyContentFromDetail(parsed || {}, {
sn_code,
platform: platform_type,
friendId: friend_id,
encryptFriendId: friend.encryptFriendId || ''
});
if (decision.replied && decision.reply_content) {
if (decision.replied) {
const action = decision.action || 'text';
const content = decision.reply_content || '';
const actionMessages = {
send_resume: [{ type: 'send_resume', content }],
exchange_wechat: [{ type: 'exchange_wechat', content }],
exchange_phone: [{ type: 'exchange_phone', content }]
};
const messages = actionMessages[action] || [{ type: 'text', content }];
const actionNames = { send_resume: '发送简历', exchange_wechat: '换微信', exchange_phone: '换电话' };
const send_command = {
command_type: 'send_chat_message',
command_name: '发送聊天消息',
command_name: actionNames[action] || '发送聊天消息',
command_params: {
platform: platform_type,
friendId: friend_id,
messages: [{ type: 'text', content: decision.reply_content }],
messages,
chatType: 'reply'
},
priority: config.getTaskPriority('auto_chat') || 6

View File

@@ -0,0 +1,81 @@
const Sequelize = require('sequelize');
/**
* 沟通回复意图 AI 调用记录
* 记录 getReplyContentFromDetail 中 replyIntentAndContent 的入参与结果,便于排查与统计
*/
module.exports = (db) => {
const chat_reply_intent_log = db.define('chat_reply_intent_log', {
sn_code: {
comment: '设备SN码',
type: Sequelize.STRING(50),
allowNull: true,
defaultValue: ''
},
platform: {
comment: '平台: boss / liepin',
type: Sequelize.STRING(20),
allowNull: true,
defaultValue: 'boss'
},
friendId: {
comment: '好友/会话ID',
type: Sequelize.BIGINT,
allowNull: true
},
encrypt_friend_id: {
comment: '好友加密ID',
type: Sequelize.STRING(100),
allowNull: true,
defaultValue: ''
},
hr_message_text: {
comment: 'HR 最新消息原文AI 入参)',
type: Sequelize.TEXT,
allowNull: true
},
action: {
comment: 'AI 返回意图: no_reply/text/send_resume/exchange_wechat/exchange_phone',
type: Sequelize.STRING(30),
allowNull: true,
defaultValue: ''
},
reply_content: {
comment: 'AI 返回的回复文案',
type: Sequelize.TEXT,
allowNull: true
},
replied: {
comment: '是否执行了回复',
type: Sequelize.BOOLEAN,
allowNull: true,
defaultValue: false
},
reason: {
comment: '未回复时的原因',
type: Sequelize.STRING(200),
allowNull: true
},
job_name: {
comment: '职位名称(便于排查)',
type: Sequelize.STRING(200),
allowNull: true
},
create_time: {
comment: '创建时间',
type: Sequelize.DATE,
allowNull: true,
defaultValue: Sequelize.NOW
}
}, {
timestamps: false,
indexes: [
{ unique: false, fields: ['sn_code', 'platform', 'friendId'] },
{ unique: false, fields: ['create_time'] }
]
});
// chat_reply_intent_log.sync({ force: true });
return chat_reply_intent_log;
};

View File

@@ -253,6 +253,65 @@ class aiService {
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_replyreply_content 留空。
2. 若 HR 明确要求发简历/投递/看简历 → action 为 send_resumereply_content 可为简短附言或空。
3. 若 HR 明确要求加微信/留微信/发微信 → action 为 exchange_wechatreply_content 可为简短附言或空。
4. 若 HR 明确要求留电话/发电话/联系方式 → action 为 exchange_phonereply_content 可为简短附言或空。
5. 若仅为普通聊天、打招呼 → action 为 textreply_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 - 简历文本内容