Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
R
recruit-sys
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
李文光
recruit-sys
Commits
babbc4bc
Commit
babbc4bc
authored
Aug 31, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: JD生成不覆盖已填字段,重写为专业JD结构(职责4-6条/要求分层)
parent
d392f9db
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
117 additions
and
17 deletions
+117
-17
jd_grill.py
backend/app/services/jd_grill.py
+6
-2
llm.py
backend/app/services/llm.py
+78
-10
qwenpaw_client.py
backend/app/services/qwenpaw_client.py
+9
-1
test_ai.py
backend/tests/test_ai.py
+23
-0
JdGrillModal.vue
vue-app/src/components/jobs/JdGrillModal.vue
+1
-4
No files found.
backend/app/services/jd_grill.py
View file @
babbc4bc
...
@@ -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 组 · 排除与来源 ----------
...
...
backend/app/services/llm.py
View file @
babbc4bc
...
@@ -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"
,
...
...
backend/app/services/qwenpaw_client.py
View file @
babbc4bc
...
@@ -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)}"
)
)
...
...
backend/tests/test_ai.py
View file @
babbc4bc
...
@@ -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
vue-app/src/components/jobs/JdGrillModal.vue
View file @
babbc4bc
...
@@ -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
||
''
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment