1
This commit is contained in:
@@ -1,14 +1,62 @@
|
||||
/**
|
||||
* 根据 get_job_listings 返回的 Tab 列表,用 AI 更新 job_types 的 description / excludeKeywords / commonSkills
|
||||
* get_job_listings 后:按投递 Tab 确保 job_types 存在(无则创建);
|
||||
* 「推荐」标签结合在线简历由 AI 生成描述与关键词后再落库;
|
||||
* 再根据 resume.deliver_tab_label 同步 pla_account.job_type_id;
|
||||
* 最后对当前投递对应的 job_types 做一轮 AI 更新。
|
||||
*/
|
||||
const db = require('../middleware/dbProxy');
|
||||
const aiService = require('./ai_service');
|
||||
|
||||
/**
|
||||
* 从模型回复中解析 JSON 对象
|
||||
* @param {string} text
|
||||
* @returns {object|null}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRecommendTab(text) {
|
||||
const t = String(text || '').trim();
|
||||
return t === '推荐' || /^推荐[·•\s]*/.test(t) || t.startsWith('推荐');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Tab 文案推断「职位标题须包含」子串(如「售前工程师」→「售前」);推荐类返回 [],交给 AI。
|
||||
* 与 commonSkills 无关,仅供标题子串过滤。
|
||||
*/
|
||||
function deriveTitleIncludeKeywordsFromTabName(text) {
|
||||
const s = String(text || '').trim();
|
||||
if (!s || isRecommendTab(s)) return [];
|
||||
const m = s.match(/^([\u4e00-\u9fff]{2})[\u4e00-\u9fff]{2,}/);
|
||||
if (m) return [m[1]];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} resume - resume_info 实例或 plain
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildResumeSnippet(resume) {
|
||||
if (!resume) return '';
|
||||
const r = typeof resume.toJSON === 'function' ? resume.toJSON() : resume;
|
||||
const parts = [];
|
||||
if (r.expectedPosition) parts.push(`期望职位: ${r.expectedPosition}`);
|
||||
if (r.expectedIndustry) parts.push(`期望行业: ${r.expectedIndustry}`);
|
||||
if (r.currentPosition) parts.push(`当前职位: ${r.currentPosition}`);
|
||||
if (r.currentCompany) parts.push(`当前公司: ${r.currentCompany}`);
|
||||
if (r.workYears) parts.push(`工作年限: ${r.workYears}`);
|
||||
if (r.education) parts.push(`学历: ${r.education}`);
|
||||
if (r.major) parts.push(`专业: ${r.major}`);
|
||||
let skills = r.skills;
|
||||
if (typeof skills === 'string' && skills) {
|
||||
try {
|
||||
const arr = JSON.parse(skills);
|
||||
if (Array.isArray(arr)) parts.push(`技能标签: ${arr.join('、')}`);
|
||||
} catch (e) {
|
||||
parts.push(`技能: ${skills}`);
|
||||
}
|
||||
}
|
||||
if (r.skillDescription) parts.push(`技能描述: ${String(r.skillDescription).slice(0, 500)}`);
|
||||
if (r.resumeContent) parts.push(`简历摘录:\n${String(r.resumeContent).slice(0, 2800)}`);
|
||||
return parts.join('\n') || '(暂无简历正文,仅根据「推荐」标签生成通用配置)';
|
||||
}
|
||||
|
||||
function parseJsonFromAi(text) {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
let s = text.trim();
|
||||
@@ -30,60 +78,254 @@ function parseJsonFromAi(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功拉取 job_listings 后:若账号绑定了 job_type_id,则用 AI 更新对应 job_types 行
|
||||
* @param {string} sn_code
|
||||
* @param {Array<{ index?: number, text?: string }>} tabs
|
||||
* @param {string} platform
|
||||
* @returns {Promise<{ updated: boolean, jobTypeId?: number }|null>}
|
||||
* 「推荐」类 Tab:无记录时根据在线简历调用 AI 创建 job_types。
|
||||
* name 必须与网页 get_job_listings 返回的 text 完全一致(含「推荐」等字样)。
|
||||
*/
|
||||
async function createRecommendJobTypeIfNeeded(job_types, pla_account, resume, platform, sortOrder, tabText) {
|
||||
const tabName = String(tabText != null ? tabText : '').trim().slice(0, 100);
|
||||
if (!tabName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await job_types.findOne({
|
||||
where: { name: tabName, pla_account_id: pla_account.id }
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const snippet = buildResumeSnippet(resume);
|
||||
const prompt = `用户在 Boss 直聘使用投递标签「${tabName}」(名称须与页面 Tab 一致)。请根据以下在线简历/期望信息,生成该投递方向的说明与关键词(用于自动投递过滤与匹配)。
|
||||
|
||||
${snippet}
|
||||
|
||||
请只输出一段 JSON(不要 Markdown),格式:
|
||||
{"description":"80~200字中文","excludeKeywords":["5~12个排除词"],"commonSkills":["8~20个技能关键词"],"titleIncludeKeywords":["1~4个简短子串"]}
|
||||
|
||||
excludeKeywords:不适合投递的岗位特征词(如与简历方向不符的销售、客服等,按简历推断)。
|
||||
commonSkills:与简历主线一致的技能与技术栈,仅用于简历技能匹配加分,不得替代标题关键词。
|
||||
titleIncludeKeywords:职位名称(标题)中须同时包含的子串,用于过滤岗位;例如 Tab 为「售前工程师」时应有「售前」。勿把技能栈写进此数组。`;
|
||||
|
||||
let description = '';
|
||||
let excludeArr = [];
|
||||
let skillsArr = [];
|
||||
let titleIncArr = [];
|
||||
|
||||
try {
|
||||
const { content } = await aiService.callAPI(prompt, {
|
||||
systemPrompt: '你只输出合法 JSON,键为 description、excludeKeywords、commonSkills、titleIncludeKeywords。',
|
||||
temperature: 0.35,
|
||||
maxTokens: 2000,
|
||||
sn_code: pla_account.sn_code,
|
||||
service_type: 'job_type_sync',
|
||||
business_type: 'job_type_recommend_create'
|
||||
});
|
||||
const parsed = parseJsonFromAi(content);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
description = String(parsed.description || '').slice(0, 4000);
|
||||
excludeArr = Array.isArray(parsed.excludeKeywords) ? parsed.excludeKeywords.map(String) : [];
|
||||
skillsArr = Array.isArray(parsed.commonSkills) ? parsed.commonSkills.map(String) : [];
|
||||
titleIncArr = Array.isArray(parsed.titleIncludeKeywords)
|
||||
? parsed.titleIncludeKeywords.map(String).map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[job_type_ai_sync] 推荐标签 AI 创建失败,使用占位:', e.message);
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
description = `Boss 页面标签「${tabName}」流职位,与在线简历期望综合匹配。`;
|
||||
}
|
||||
if (excludeArr.length === 0) {
|
||||
excludeArr = ['普工', '纯客服'];
|
||||
}
|
||||
if (skillsArr.length === 0) {
|
||||
skillsArr = ['沟通', '协作'];
|
||||
}
|
||||
if (titleIncArr.length === 0) {
|
||||
titleIncArr = deriveTitleIncludeKeywordsFromTabName(tabName);
|
||||
}
|
||||
|
||||
const row = await job_types.create({
|
||||
name: tabName,
|
||||
description,
|
||||
excludeKeywords: JSON.stringify(excludeArr),
|
||||
commonSkills: JSON.stringify(skillsArr),
|
||||
titleIncludeKeywords: JSON.stringify(titleIncArr),
|
||||
is_enabled: 1,
|
||||
sort_order: sortOrder,
|
||||
pla_account_id: pla_account.id
|
||||
});
|
||||
console.log(`[job_type_ai_sync] 已创建 job_types id=${row.id} name="${tabName}"(与网页标签一致) account=${pla_account.id}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通 Tab:无则插入占位 job_types
|
||||
*/
|
||||
async function ensureSimpleTabJobType(job_types, pla_account, tabText, sortOrder) {
|
||||
const name = String(tabText).trim().slice(0, 100);
|
||||
if (!name) return null;
|
||||
|
||||
let row = await job_types.findOne({
|
||||
where: { name, pla_account_id: pla_account.id }
|
||||
});
|
||||
if (row) return row;
|
||||
|
||||
const titleKws = deriveTitleIncludeKeywordsFromTabName(name);
|
||||
row = await job_types.create({
|
||||
name,
|
||||
description: '',
|
||||
excludeKeywords: '[]',
|
||||
commonSkills: '[]',
|
||||
titleIncludeKeywords: JSON.stringify(titleKws),
|
||||
is_enabled: 1,
|
||||
sort_order: sortOrder,
|
||||
pla_account_id: pla_account.id
|
||||
});
|
||||
console.log(`[job_type_ai_sync] 已创建 Tab job_types id=${row.id} name=${name}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历 get_job_listings 的 Tab,按账户确保每条标签在 job_types 中有对应行
|
||||
* @returns {Promise<{ account: object, resume: object|null, tabKeyToRow: Map<string, object> }|null>}
|
||||
*/
|
||||
async function ensureJobTypesForTabs(sn_code, tabs, platform = 'boss') {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const resume_info = db.getModel('resume_info');
|
||||
const job_types = db.getModel('job_types');
|
||||
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, is_delete: 0 }
|
||||
});
|
||||
if (!account) return null;
|
||||
|
||||
const resume = await resume_info.findOne({
|
||||
where: { sn_code, platform, isActive: true },
|
||||
order: [['last_modify_time', 'DESC']]
|
||||
});
|
||||
|
||||
const tabTexts = (tabs || [])
|
||||
.map((t) => (t && t.text != null ? String(t.text).trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const tabKeyToRow = new Map();
|
||||
|
||||
for (let i = 0; i < tabTexts.length; i++) {
|
||||
const raw = tabTexts[i];
|
||||
const sortOrder = i;
|
||||
|
||||
if (isRecommendTab(raw)) {
|
||||
const row = await createRecommendJobTypeIfNeeded(
|
||||
job_types,
|
||||
account,
|
||||
resume,
|
||||
platform,
|
||||
sortOrder,
|
||||
raw
|
||||
);
|
||||
if (row) tabKeyToRow.set(raw, row);
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = await ensureSimpleTabJobType(job_types, account, raw, sortOrder);
|
||||
if (row) tabKeyToRow.set(raw, row);
|
||||
}
|
||||
|
||||
const label = resume && resume.deliver_tab_label ? String(resume.deliver_tab_label).trim() : '';
|
||||
if (label) {
|
||||
let targetRow = tabKeyToRow.get(label);
|
||||
if (!targetRow && isRecommendTab(label)) {
|
||||
for (const [k, v] of tabKeyToRow.entries()) {
|
||||
if (isRecommendTab(k)) {
|
||||
targetRow = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!targetRow) {
|
||||
targetRow = await job_types.findOne({
|
||||
where: { name: label.slice(0, 100), pla_account_id: account.id }
|
||||
});
|
||||
}
|
||||
if (targetRow && targetRow.id !== account.job_type_id) {
|
||||
await pla_account.update({ job_type_id: targetRow.id }, { where: { id: account.id } });
|
||||
account.job_type_id = targetRow.id;
|
||||
console.log(`[job_type_ai_sync] 已同步 pla_account.job_type_id=${targetRow.id} ← deliver_tab_label「${label}」`);
|
||||
}
|
||||
}
|
||||
|
||||
return { account, resume, tabKeyToRow };
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功拉取 job_listings 后:确保标签行存在 → 同步 job_type_id → AI 更新当前投递类型
|
||||
*/
|
||||
async function maybeSyncAfterListings(sn_code, tabs, platform = 'boss') {
|
||||
if (!sn_code) return null;
|
||||
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code, is_delete: 0 }
|
||||
});
|
||||
if (!account || !account.job_type_id) {
|
||||
return null;
|
||||
}
|
||||
const ensured = await ensureJobTypesForTabs(sn_code, tabs, platform);
|
||||
if (!ensured) return null;
|
||||
|
||||
const { account, resume, tabKeyToRow } = ensured;
|
||||
const job_types = db.getModel('job_types');
|
||||
const jobType = await job_types.findByPk(account.job_type_id);
|
||||
if (!jobType) {
|
||||
console.warn('[job_type_ai_sync] job_types 不存在, id=', account.job_type_id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabTexts = (tabs || [])
|
||||
.map((t) => (t && t.text != null ? String(t.text).trim() : ''))
|
||||
.filter(Boolean);
|
||||
if (tabTexts.length === 0) {
|
||||
console.warn('[job_type_ai_sync] Tab 列表为空,跳过 AI 更新');
|
||||
console.warn('[job_type_ai_sync] Tab 列表为空,跳过后续 AI 更新');
|
||||
return null;
|
||||
}
|
||||
|
||||
let jobType = null;
|
||||
const label = resume && resume.deliver_tab_label ? String(resume.deliver_tab_label).trim() : '';
|
||||
|
||||
if (label) {
|
||||
jobType = tabKeyToRow.get(label);
|
||||
if (!jobType && isRecommendTab(label)) {
|
||||
for (const [k, v] of tabKeyToRow.entries()) {
|
||||
if (isRecommendTab(k)) {
|
||||
jobType = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!jobType && account.job_type_id) {
|
||||
jobType = await job_types.findByPk(account.job_type_id);
|
||||
}
|
||||
if (!jobType && tabTexts.length > 0) {
|
||||
jobType = tabKeyToRow.get(tabTexts[0]);
|
||||
}
|
||||
if (!jobType) {
|
||||
console.warn('[job_type_ai_sync] 无法解析当前投递 job_types,跳过 AI 更新');
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeName = jobType.name || '';
|
||||
const labelsStr = tabTexts.join('、');
|
||||
const typeName = jobType.name || '';
|
||||
|
||||
const prompt = `你是招聘平台求职助手。用户在某招聘网站的「期望职类/Tab」列表如下(按顺序):
|
||||
${labelsStr}
|
||||
|
||||
当前在系统中登记的职位类型名称为:「${typeName}」
|
||||
当前重点维护的职位类型名称为:「${typeName}」
|
||||
平台标识:${platform}
|
||||
|
||||
请根据上述 Tab 名称,补充该求职方向的说明、自动投递时应排除的岗位关键词、以及该方向常见技能关键词。
|
||||
请根据上述 Tab 名称,补充该求职方向的说明、自动投递时应排除的岗位关键词、常见技能关键词、以及职位标题须包含的子串。
|
||||
|
||||
请只输出一段 JSON(不要 Markdown 代码块,不要其它说明),格式严格如下:
|
||||
{"description":"50~200字中文,概括该求职方向","excludeKeywords":["关键词1","关键词2"],"commonSkills":["技能1","技能2"]}
|
||||
{"description":"50~200字中文,概括该求职方向","excludeKeywords":["关键词1","关键词2"],"commonSkills":["技能1","技能2"],"titleIncludeKeywords":["子串1","子串2"]}
|
||||
|
||||
要求:
|
||||
- description:面向求职者的简短说明。
|
||||
- excludeKeywords:5~12 个字符串,用于过滤明显不合适的岗位(如用户做研发可排除「纯销售」「客服」等,按 Tab 语义推断)。
|
||||
- commonSkills:8~20 个字符串,该方向常见技能或技术栈关键词,用于匹配加分。
|
||||
- 所有字符串使用中文或业界通用英文技术词均可。`;
|
||||
- excludeKeywords:5~12 个字符串,用于过滤明显不合适的岗位。
|
||||
- commonSkills:8~20 个字符串,该方向常见技能或技术栈关键词,仅用于简历技能匹配加分,不得替代标题关键词。
|
||||
- titleIncludeKeywords:1~4 个短子串,投递时岗位标题须**同时包含**这些子串(如「售前工程师」对应含「售前」);勿把编程技能写进此数组。`;
|
||||
|
||||
const { content } = await aiService.callAPI(prompt, {
|
||||
systemPrompt: '你只输出合法 JSON 对象,键为 description、excludeKeywords、commonSkills,不要输出其它文字。',
|
||||
systemPrompt: '你只输出合法 JSON 对象,键为 description、excludeKeywords、commonSkills、titleIncludeKeywords,不要输出其它文字。',
|
||||
temperature: 0.35,
|
||||
maxTokens: 2000,
|
||||
sn_code,
|
||||
@@ -99,19 +341,26 @@ ${labelsStr}
|
||||
const description = String(parsed.description || '').slice(0, 4000);
|
||||
const excludeArr = Array.isArray(parsed.excludeKeywords) ? parsed.excludeKeywords.map(String) : [];
|
||||
const skillsArr = Array.isArray(parsed.commonSkills) ? parsed.commonSkills.map(String) : [];
|
||||
let titleIncArr = Array.isArray(parsed.titleIncludeKeywords)
|
||||
? parsed.titleIncludeKeywords.map(String).map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
if (titleIncArr.length === 0) {
|
||||
titleIncArr = deriveTitleIncludeKeywordsFromTabName(typeName);
|
||||
}
|
||||
|
||||
await job_types.update(
|
||||
{
|
||||
description,
|
||||
excludeKeywords: JSON.stringify(excludeArr),
|
||||
commonSkills: JSON.stringify(skillsArr),
|
||||
titleIncludeKeywords: JSON.stringify(titleIncArr),
|
||||
pla_account_id: account.id
|
||||
},
|
||||
{ where: { id: jobType.id } }
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[job_type_ai_sync] 已更新 job_types id=${jobType.id} pla_account_id=${account.id} exclude=${excludeArr.length} skills=${skillsArr.length}`
|
||||
`[job_type_ai_sync] 已更新 job_types id=${jobType.id} name=${typeName} pla_account_id=${account.id} exclude=${excludeArr.length} skills=${skillsArr.length} titleKw=${titleIncArr.length}`
|
||||
);
|
||||
|
||||
return { updated: true, jobTypeId: jobType.id };
|
||||
@@ -119,5 +368,8 @@ ${labelsStr}
|
||||
|
||||
module.exports = {
|
||||
maybeSyncAfterListings,
|
||||
parseJsonFromAi
|
||||
ensureJobTypesForTabs,
|
||||
parseJsonFromAi,
|
||||
isRecommendTab,
|
||||
deriveTitleIncludeKeywordsFromTabName
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user