Commit babbc4bc authored by 李文光's avatar 李文光

feat: JD生成不覆盖已填字段,重写为专业JD结构(职责4-6条/要求分层)

parent d392f9db
...@@ -142,6 +142,8 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -142,6 +142,8 @@ QUESTION_BANK: list[dict[str, Any]] = [
], ],
"free_text": True, "free_text": True,
"parse_text": lambda text: {"responsibilities": text.strip()[:300]}, "parse_text": lambda text: {"responsibilities": text.strip()[:300]},
# 已填过职责就不重复问,避免用选项文案覆盖用户已写内容
"skip_when": lambda job: bool(job.get("responsibilities")),
}, },
# ---------- 第 2 组 · 硬门槛 ---------- # ---------- 第 2 组 · 硬门槛 ----------
...@@ -196,12 +198,10 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -196,12 +198,10 @@ QUESTION_BANK: list[dict[str, Any]] = [
_opt("深圳、现场办公、不接受远程", "shenzhen-onsite", { _opt("深圳、现场办公、不接受远程", "shenzhen-onsite", {
"location": "深圳", "location": "深圳",
"remote": "现场办公,不接受远程", "remote": "现场办公,不接受远程",
"salaryRange": "待定",
}), }),
_opt("可远程/混合办公", "remote", { _opt("可远程/混合办公", "remote", {
"location": "不限", "location": "不限",
"remote": "可远程/混合办公", "remote": "可远程/混合办公",
"salaryRange": "待定",
}), }),
_opt("有明确带宽(填数字)", "explicit-salary", { _opt("有明确带宽(填数字)", "explicit-salary", {
"salaryRange": "待你回填区间", "salaryRange": "待你回填区间",
...@@ -209,6 +209,8 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -209,6 +209,8 @@ QUESTION_BANK: list[dict[str, Any]] = [
], ],
"free_text": True, "free_text": True,
"parse_text": lambda text: {"location": "深圳", "salaryRange": text[:40], "remote": text[:40]}, "parse_text": lambda text: {"location": "深圳", "salaryRange": text[:40], "remote": text[:40]},
# 用户已填过薪资范围就跳过本题,办公地点选项不覆盖已填薪资
"skip_when": lambda job: bool(job.get("salaryRange")),
}, },
# ---------- 第 3 组 · 命脉与验证(技术岗额外 9a) ---------- # ---------- 第 3 组 · 命脉与验证(技术岗额外 9a) ----------
...@@ -274,6 +276,8 @@ QUESTION_BANK: list[dict[str, Any]] = [ ...@@ -274,6 +276,8 @@ QUESTION_BANK: list[dict[str, Any]] = [
], ],
"free_text": True, "free_text": True,
"parse_text": lambda text: {"techStack": text.strip()[:120], "requirements": text.strip()[:200]}, "parse_text": lambda text: {"techStack": text.strip()[:120], "requirements": text.strip()[:200]},
# 已填过任职要求就不重复问,避免覆盖用户已写内容
"skip_when": lambda job: bool(job.get("requirements")),
}, },
# ---------- 第 4 组 · 排除与来源 ---------- # ---------- 第 4 组 · 排除与来源 ----------
......
...@@ -230,25 +230,69 @@ async def analyze_resume(payload: dict[str, Any]) -> dict[str, Any]: ...@@ -230,25 +230,69 @@ async def analyze_resume(payload: dict[str, Any]) -> dict[str, Any]:
def local_jd_draft(payload: dict[str, Any]) -> dict[str, Any]: def local_jd_draft(payload: dict[str, Any]) -> dict[str, Any]:
"""无 LLM 时的本地结构化 JD:优先使用访谈/手填的具体字段,缺的用岗位推导的通用条目补齐。"""
job = payload.get("job") or {} job = payload.get("job") or {}
mode = payload.get("mode") or "generate" mode = payload.get("mode") or "generate"
title = job.get("title") or "目标岗位" title = job.get("title") or "目标岗位"
department = job.get("department") or "业务部门" department = job.get("department") or "业务部门"
positioning = job.get("positioning") or ""
education = job.get("education") or ""
years = job.get("experienceYears") or ""
tech_stack = job.get("techStack") or ""
must = str(job.get("mustHave") or "").strip()
nice = str(job.get("niceToHave") or "").strip()
salary = job.get("salaryRange") or ""
work_location = job.get("workLocation") or job.get("location") or ""
# 岗位说明:优先用访谈采集的业务定位,否则按岗位/部门组织一句使命定位
jd_text = positioning or f"加入{department},负责{title}的核心交付,支撑业务目标达成;与产品、研发、运营等团队协同,保障交付质量与进度。"
# 核心职责:访谈/手填过就用原文(已是具体动作),否则按岗位推导 4 条通用条目
raw_responsibilities = str(job.get("responsibilities") or "").strip()
if raw_responsibilities:
resp_lines = [line.strip() for line in re.split(r"[;\n]+", raw_responsibilities) if line.strip()]
responsibilities_text = "\n".join(resp_lines)
else:
responsibilities_text = "\n".join(
[
f"负责{title}相关业务与项目的规划、执行和结果跟进;",
f"协同{department}及跨部门团队推进需求落地,保证交付质量与进度;",
"参与方案评审、复盘与标准沉淀,持续优化协作与交付流程;",
"跟进线上问题与风险,及时处置并推动闭环。",
]
)
# 任职要求:把访谈采集的学历/年限/技术栈/命脉拼成分层条目
req_parts: list[str] = []
for part in [education, f"{years}相关经验" if years else "", tech_stack]:
if part:
req_parts.append(part)
if must:
for line in re.split(r"[;\n]+", must):
if line.strip():
req_parts.append(line.strip())
req_parts.append("具备良好的沟通协同、问题分析与推进能力")
requirements_text = ";".join(dict.fromkeys(req_parts)) if req_parts else "具备岗位相关经验,能独立完成核心交付"
location_note = f";工作地点:{work_location}" if work_location else ""
salary_note = f";薪资范围:{salary}" if salary else ""
return { return {
"provider": "local-structured", "provider": "local-structured",
"mode": mode, "mode": mode,
"jdVersion": "v2 待确认" if mode == "iterate" else "v1 待确认", "jdVersion": "v2 待确认" if mode == "iterate" else "v1 待确认",
"jdStatus": "待确认", "jdStatus": "待确认",
"approvalStatus": "用人部门确认中", "approvalStatus": "用人部门确认中",
"jd": f"{department}{title}负责围绕业务目标完成岗位核心交付,需具备相关行业理解、专业能力和跨部门协同能力。", "jd": jd_text,
"responsibilities": f"负责{title}相关业务规划、执行和结果跟进。\n协同用人部门、HR 和相关团队推进招聘目标。\n沉淀岗位标准、业务要求和候选人评估依据。", "responsibilities": responsibilities_text,
"requirements": f"具备{title}相关经验。\n理解{department}业务场景和关键交付。\n具备良好的沟通协同、问题分析和推进能力。", "requirements": requirements_text,
"mustHave": "岗位相关经验\n核心业务能力\n稳定的交付记录", "mustHave": must or "岗位相关核心经验\n端到端独立交付能力\n稳定的交付记录",
"niceToHave": "能源电力行业经验\n复杂项目推进经验\n数据分析能力", "niceToHave": nice or "能源电力行业经验\n复杂项目推进经验\n数据分析能力",
"knockout": "核心经验明显不匹配\n无法接受岗位关键工作场景\n简历信息关键字段缺失且无法补充", "knockout": job.get("knockout") or "核心经验明显不匹配\n无法接受岗位关键工作场景\n简历信息关键字段缺失且无法补充",
"competency": job.get("competency") or "专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进", "competency": job.get("competency") or "专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进",
"matchKeywords": ",".join([item for item in [title, department, "业务理解", "沟通协同", "数据分析"] if item]), "matchKeywords": ",".join(
"nextAction": "推送用人部门确认 JD 草稿。", dict.fromkeys([item for item in [title, department, tech_stack, "业务理解", "沟通协同", "数据分析"] if item])
),
"nextAction": f"推送用人部门确认 JD 草稿。{salary_note}{location_note}",
} }
...@@ -256,8 +300,32 @@ async def llm_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None: ...@@ -256,8 +300,32 @@ async def llm_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None:
settings = get_settings() settings = get_settings()
if not settings.effective_llm_api_key: if not settings.effective_llm_api_key:
return None return None
mode_text = "基于现有JD进行版本迭代" if payload.get("mode") == "iterate" else "结合岗位信息生成JD草稿" mode = payload.get("mode") or "generate"
user = f"请{mode_text},返回 JSON。字段必须为:jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHave, niceToHave, knockout, competency, matchKeywords, nextAction。\n\n岗位信息:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}" if mode == "iterate":
task_line = "这是对已有 JD 的版本迭代:保留仍然有效的内容,在此基础上改写、细化、补强,不要推翻重写,也不要照抄原文。"
else:
task_line = "这是从零生成 JD 草稿:把零散岗位信息扩写成完整、专业、可直接发布的中文招聘 JD。"
user = f"""你是一名资深招聘 JD 顾问。{task_line}
只返回一个 JSON 对象(不要 Markdown 围栏、不要多余解释),键必须为:
jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHave, niceToHave, knockout, competency, matchKeywords, nextAction
各字段写作规范:
1. jd(岗位说明/JD):1-2 句话,说明岗位在公司的业务定位、要解决什么问题、对业务的价值。以动词或"负责/加入"开头,不堆砌职责,不出现"待定""待确认"等占位词。
2. responsibilities(核心职责):4-6 条,每条以动词开头(负责/参与/主导/推动/输出/跟进),写成"做什么 + 对谁交付 + 达到什么结果"的完整动作,覆盖从需求到交付的关键链路;不要用"负责相关业务"这类空洞表述。
3. requirements(任职要求):分三个层次依次列出——① 硬性门槛(学历、经验年限、核心技术栈,用明确数字,如"本科及以上""3 年以上");② 核心能力(能独立完成的关键能力);③ 软素质(沟通、协同、抗压等)。层次之间用换行区分。
4. mustHave(命脉/硬性条件):1-3 条"缺了就不对口"的一票否决项,宁缺毋滥。
5. niceToHave(加分项):2-4 条非硬性加分项(行业经验、特定工具、证书等)。
6. knockout(排除信号):2-4 条"简历看着像其实不对"的画像。
7. competency(素质模型):5-6 项软素质,用换行分隔。
8. matchKeywords(匹配关键词):6-10 个岗位关键词,用逗号分隔,便于简历初筛。
9. jdVersion 用"v1 待确认"/"v2 待确认"格式;jdStatus="待确认";approvalStatus="用人部门确认中";nextAction 给一句下一步建议。
写作要求:
- 不要照抄输入里已有的职责/要求文案,要在其基础上扩写、细化、专业化;
- 输入信息不足时,基于岗位名称、部门、命脉合理推断,写通用但不空洞的内容,不要编造具体业务数据;
- 整体语气专业、具体、可执行,让候选人读完知道"来了做什么、需要什么"。
岗位信息:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}"""
async with httpx.AsyncClient(timeout=60) as client: async with httpx.AsyncClient(timeout=60) as client:
response = await client.post( response = await client.post(
f"{settings.effective_llm_base_url}/chat/completions", f"{settings.effective_llm_base_url}/chat/completions",
......
...@@ -255,12 +255,20 @@ async def qwenpaw_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None: ...@@ -255,12 +255,20 @@ async def qwenpaw_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None:
"mode": mode, "mode": mode,
} }
mode_text = "基于现有JD进行版本迭代" if mode == "iterate" else "结合岗位信息生成JD草稿" mode_text = "基于现有JD进行版本迭代:保留仍然有效的内容,改写、细化、补强,不推翻重写、不照抄原文" if mode == "iterate" else "从零生成JD草稿:把零散岗位信息扩写成完整、专业、可直接发布的中文招聘JD"
prompt = ( prompt = (
"你是招聘中的资深JD顾问。请按 recruit-grill 技能内部梳理后,直接产出可用于审批的 JD。\n" "你是招聘中的资深JD顾问。请按 recruit-grill 技能内部梳理后,直接产出可用于审批的 JD。\n"
"只返回一个 JSON 对象(不要 Markdown 围栏、不要解释),字段必须为:" "只返回一个 JSON 对象(不要 Markdown 围栏、不要解释),字段必须为:"
"jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHave, niceToHave, " "jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHave, niceToHave, "
"knockout, competency, matchKeywords, nextAction。\n" "knockout, competency, matchKeywords, nextAction。\n"
"各字段写作规范:\n"
"1. jd(岗位说明):1-2 句话,说明岗位的业务定位、要解决的问题、对业务的价值,以动词或'负责/加入'开头,不出现'待定''待确认'等占位词。\n"
"2. responsibilities(核心职责):4-6 条,每条以动词开头(负责/参与/主导/推动/输出/跟进),写成'做什么 + 对谁交付 + 达到什么结果'的完整动作,避免'负责相关业务'这类空洞表述。\n"
"3. requirements(任职要求):分三层依次列出——① 硬性门槛(学历、经验年限、核心技术栈,用明确数字);② 核心能力;③ 软素质。层次间用换行区分。\n"
"4. mustHave(命脉):1-3 条一票否决项;5. niceToHave(加分项):2-4 条;6. knockout(排除信号):2-4 条'看着像其实不对'的画像。\n"
"7. competency(素质模型):5-6 项软素质,换行分隔;8. matchKeywords:6-10 个岗位关键词,逗号分隔,便于简历初筛。\n"
"9. jdVersion 用'v1 待确认'/'v2 待确认';jdStatus='待确认';approvalStatus='用人部门确认中';nextAction 给一句下一步建议。\n"
"写作要求:不要照抄输入里已有的职责/要求文案,要扩写、细化、专业化;输入不足时基于岗位名称/部门/命脉合理推断,不编造具体业务数据。\n"
f"本次为:{mode_text}。\n" f"本次为:{mode_text}。\n"
f"岗位信息:{json.dumps(job, ensure_ascii=False, indent=2)}" f"岗位信息:{json.dumps(job, ensure_ascii=False, indent=2)}"
) )
......
...@@ -87,3 +87,26 @@ def test_grill_skips_existing_fields(client): ...@@ -87,3 +87,26 @@ def test_grill_skips_existing_fields(client):
# 第一问不是 entry_path(已有 jd 跳过入口分流) # 第一问不是 entry_path(已有 jd 跳过入口分流)
first_qid = s["next"]["question_id"] first_qid = s["next"]["question_id"]
assert first_qid not in ("entry_path", "input_form", "hard_gate", "lifeline", "knockout") assert first_qid not in ("entry_path", "input_form", "hard_gate", "lifeline", "knockout")
def test_grill_never_overwrites_prefilled_fields(client):
"""用户已填过 responsibilities / salaryRange / requirements 的题必须跳过,不能覆盖已填内容。"""
start = client.post(
"/api/jd/grill/start",
json={
"job": {
"title": "Java开发工程师",
"department": "技术部",
"responsibilities": "负责交易系统核心链路的设计与编码,保障高可用与低延迟。",
"salaryRange": "30-45k/月",
"requirements": "本科及以上,3 年以上 Java 开发经验,熟悉 Spring Cloud 微服务。",
},
"tech_role": True,
},
)
assert start.status_code == 200
s = start.json()
asked_ids = {s["next"]["question_id"]}
assert "responsibilities" not in asked_ids
assert "salary_location" not in asked_ids
assert "tech_stack" not in asked_ids
...@@ -68,11 +68,8 @@ const finish = async () => { ...@@ -68,11 +68,8 @@ const finish = async () => {
generating.value = true generating.value = true
try { try {
// 用 grill 累积出的结构化 job 生成 JD。后端信息完整性判断依赖 job.jd 与 job.requirements, // 用 grill 累积出的结构化 job 生成 JD。后端信息完整性判断依赖 job.jd 与 job.requirements,
// 访谈不直接采集这两个字段,因此用已有字段兜底,避免再次触发 needs_chat 死循环 // 由生成器负责从已采集字段组织 JD 正文,避免把访谈采集的职责原文直接当 jd 回显
const accumulatedJob = { ...grill.value.job } const accumulatedJob = { ...grill.value.job }
if (!accumulatedJob.jd && accumulatedJob.responsibilities) {
accumulatedJob.jd = accumulatedJob.responsibilities
}
if (!accumulatedJob.requirements) { if (!accumulatedJob.requirements) {
const edu = accumulatedJob.education || '' const edu = accumulatedJob.education || ''
const years = accumulatedJob.experienceYears || '' const years = accumulatedJob.experienceYears || ''
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment