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
8351ae19
Commit
8351ae19
authored
Sep 01, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 简历解析优先用大模型提取字段,失败回退正则
parent
72141584
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
235 additions
and
7 deletions
+235
-7
ingest.py
backend/app/routers/ingest.py
+2
-2
resume.py
backend/app/routers/resume.py
+3
-3
llm.py
backend/app/services/llm.py
+65
-0
resume_parser.py
backend/app/services/resume_parser.py
+38
-1
parse_resume.py
backend/scripts/parse_resume.py
+23
-1
test_resume_parser_llm.py
backend/tests/test_resume_parser_llm.py
+104
-0
No files found.
backend/app/routers/ingest.py
View file @
8351ae19
...
...
@@ -18,7 +18,7 @@ from backend.app.repositories.ingest_repository import (
)
from
backend.app.security
import
verify_ingest_token
from
backend.app.services.file_storage
import
save_resume_bytes
from
backend.app.services.resume_parser
import
parse_resume_path
from
backend.app.services.resume_parser
import
parse_resume_path
_async
router
=
APIRouter
()
...
...
@@ -50,7 +50,7 @@ async def ingest(request: Request, db: Session = Depends(get_db)):
content
=
await
download_pdf
(
pdf_url
,
raw
.
get
(
"cookies"
)
or
{})
saved_file
=
save_resume_bytes
(
content
,
"platform-resume.pdf"
,
"ingest"
)
try
:
parsed
=
parse_resume_path
(
Path
(
saved_file
[
"storagePath"
]))
parsed
=
await
parse_resume_path_async
(
Path
(
saved_file
[
"storagePath"
]))
except
Exception
as
exc
:
parsed
=
{
"parseWarning"
:
str
(
exc
),
"resumeText"
:
""
}
except
Exception
as
exc
:
...
...
backend/app/routers/resume.py
View file @
8351ae19
...
...
@@ -2,7 +2,7 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
from
fastapi.responses
import
FileResponse
from
backend.app.services.file_storage
import
resolve_resume_path
,
save_resume_bytes
from
backend.app.services.resume_parser
import
parse_resume_path
,
parse_uploaded_bytes
from
backend.app.services.resume_parser
import
parse_resume_path
_async
,
parse_uploaded_bytes_async
router
=
APIRouter
()
...
...
@@ -11,7 +11,7 @@ router = APIRouter()
async
def
parse_resume_upload
(
resume
:
UploadFile
=
File
(
...
))
->
dict
:
content
=
await
resume
.
read
()
try
:
return
parse_uploaded_bytes
(
content
,
resume
.
filename
or
"resume.bin"
)
return
await
parse_uploaded_bytes_async
(
content
,
resume
.
filename
or
"resume.bin"
)
except
Exception
as
exc
:
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
exc
))
from
exc
...
...
@@ -21,7 +21,7 @@ async def upload_resume(resume: UploadFile = File(...)) -> dict:
content
=
await
resume
.
read
()
saved
=
save_resume_bytes
(
content
,
resume
.
filename
or
"resume.bin"
,
"upload"
)
try
:
parsed
=
parse_resume_path
(
resolve_resume_path
(
saved
[
"storedName"
]))
parsed
=
await
parse_resume_path_async
(
resolve_resume_path
(
saved
[
"storedName"
]))
except
Exception
as
exc
:
parsed
=
{
"parseWarning"
:
str
(
exc
)}
return
{
"ok"
:
True
,
"file"
:
{
**
saved
,
"url"
:
f
"/api/resumes/{saved['storedName']}"
},
"parsed"
:
parsed
}
...
...
backend/app/services/llm.py
View file @
8351ae19
...
...
@@ -217,6 +217,71 @@ def sanitize_candidate_for_llm(candidate: dict[str, Any]) -> dict[str, Any]:
}
# 简历结构化提取:LLM 返回的字段集合(与 llm_resume_fields 的 prompt 保持一致)。
# 其中姓名/手机/邮箱/出生年月/年龄等 PII 会被本地脱敏,LLM 无法看到真实值,
# 这些字段最终以正则解析结果为准(见 resume_parser._merge_parse_result)。
LLM_RESUME_FIELDS
=
(
"name"
,
"jobTitle"
,
"phone"
,
"email"
,
"birthDate"
,
"age"
,
"years"
,
"education"
,
"school"
,
"major"
,
"city"
,
"skills"
,
)
async
def
llm_resume_fields
(
text
:
str
,
hints
:
dict
[
str
,
Any
]
|
None
=
None
)
->
dict
[
str
,
Any
]
|
None
:
"""用大模型从简历文本中提取结构化字段;未配置 Key 或调用失败时返回 None(调用方回退正则)。
发送前用正则解析结果对姓名/手机/邮箱/城市/出生年月/年龄做本地脱敏,
被脱敏的字段 LLM 一律返回空值,最终以正则结果为准。
"""
settings
=
get_settings
()
if
not
settings
.
effective_llm_api_key
:
return
None
sanitized
=
sanitize_resume_for_llm
(
text
,
hints
or
{})
if
not
sanitized
.
strip
():
return
None
user
=
(
"请从下面的简历文本中提取结构化字段,只返回一个 JSON 对象(不要 Markdown 围栏,不要多余解释)。
\n\n
"
"字段与规则:
\n
"
'- name: 姓名(2-4 个中文字符)。姓名已被 [本地脱敏] 占位符替换时返回空字符串 ""。
\n
'
'- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",'
"不要根据教育/技能内容推断岗位。
\n
"
'- phone: 手机号。已被 [手机号已本地脱敏] 替换或没有时返回 ""。
\n
'
'- email: 邮箱。已被 [邮箱已本地脱敏] 替换或没有时返回 ""。
\n
'
'- birthDate: 出生日期,格式 YYYY-MM-DD。已被 [出生年月已本地脱敏] 替换或没有时返回 ""。
\n
'
'- age: 年龄数字(如 28)。已被 [年龄已本地脱敏] 替换或没有时返回 ""。
\n
'
'- years: 工作年限,如 "5年" / "3.5年" / "应届生";简历没有明确写工作年限时返回 "",不要从无关时间推算。
\n
'
"- education: 学历,只能是 博士/硕士/本科/大专/中专/高中/其他 之一;没有明确信息返回
\"\"
。
\n
"
'- school: 毕业院校全称(如"甘肃农业大学");没有则返回 ""。
\n
'
'- major: 专业名称(如"农林经济管理")。主修课程列表里的课程名不是专业,应取"专业"字段或紧邻学校/时间行后的'
'独立专业名;没有明确专业返回 ""。
\n
'
'- city: 现居城市或简历中的城市名(如"太原",不含"市");没有则返回 ""。
\n
'
"- skills: 技能数组,最多 12 项,只取简历中明确提到的技能。
\n\n
"
"要求:只提取简历中明确出现的信息,绝不编造或猜测;已被脱敏占位符替换的字段一律返回空字符串/空数组。
\n\n
"
f
"简历文本:
\n
{sanitized[:16000]}"
)
try
:
parsed
=
await
_chat_json
(
settings
,
"你是企业招聘系统中的资深简历结构化解析助手。只返回 JSON,不要输出 Markdown。"
,
user
,
temperature
=
0
,
)
except
Exception
:
return
None
if
not
isinstance
(
parsed
,
dict
):
return
None
return
{
key
:
parsed
.
get
(
key
)
for
key
in
LLM_RESUME_FIELDS
}
async
def
llm_resume_analysis
(
payload
:
dict
[
str
,
Any
])
->
dict
[
str
,
Any
]
|
None
:
settings
=
get_settings
()
if
not
settings
.
effective_llm_api_key
:
...
...
backend/app/services/resume_parser.py
View file @
8351ae19
...
...
@@ -2,10 +2,12 @@ from pathlib import Path
from
tempfile
import
TemporaryDirectory
from
backend.app.services.file_storage
import
sanitize_filename
from
backend.app.services.resume_parser_core
import
parse_resume
from
backend.app.services.llm
import
LLM_RESUME_FIELDS
,
llm_resume_fields
from
backend.app.services.resume_parser_core
import
parse_resume
,
school_tags
def
parse_resume_path
(
path
:
Path
)
->
dict
:
"""纯正则解析入口(无 LLM 时的兜底/调试路径)。"""
return
parse_resume
(
path
)
...
...
@@ -14,3 +16,38 @@ def parse_uploaded_bytes(content: bytes, original_name: str = "resume.bin") -> d
path
=
Path
(
temp_dir
)
/
sanitize_filename
(
original_name
)
path
.
write_bytes
(
content
)
return
parse_resume_path
(
path
)
async
def
parse_resume_path_async
(
path
:
Path
)
->
dict
:
"""LLM 优先、正则兜底的简历解析入口(API/CLI 均走这里)。"""
rule_result
=
parse_resume
(
path
)
llm_result
=
await
llm_resume_fields
(
rule_result
.
get
(
"resumeText"
)
or
""
,
rule_result
)
return
_merge_parse_result
(
rule_result
,
llm_result
)
async
def
parse_uploaded_bytes_async
(
content
:
bytes
,
original_name
:
str
=
"resume.bin"
)
->
dict
:
with
TemporaryDirectory
(
prefix
=
"recruitment-resume-"
)
as
temp_dir
:
path
=
Path
(
temp_dir
)
/
sanitize_filename
(
original_name
)
path
.
write_bytes
(
content
)
return
await
parse_resume_path_async
(
path
)
def
_merge_parse_result
(
rule_result
:
dict
,
llm_result
:
dict
|
None
)
->
dict
:
"""LLM 结果优先、正则结果兜底:LLM 字段非空则覆盖,否则保留正则值。
技能取并集去重(LLM 优先);schoolTags 依据最终 school 重新推导。
"""
result
=
dict
(
rule_result
)
if
not
isinstance
(
llm_result
,
dict
):
return
result
for
key
in
LLM_RESUME_FIELDS
:
value
=
llm_result
.
get
(
key
)
if
isinstance
(
value
,
str
):
value
=
value
.
strip
()
if
value
not
in
(
None
,
""
,
[],
{}):
result
[
key
]
=
value
llm_skills
=
[
str
(
item
)
.
strip
()
for
item
in
llm_result
.
get
(
"skills"
)
or
[]
if
str
(
item
)
.
strip
()]
if
llm_skills
:
result
[
"skills"
]
=
list
(
dict
.
fromkeys
([
*
llm_skills
,
*
(
rule_result
.
get
(
"skills"
)
or
[])]))
result
[
"schoolTags"
]
=
school_tags
(
result
.
get
(
"school"
)
or
""
)
return
result
backend/scripts/parse_resume.py
View file @
8351ae19
"""Command-line entry point for the backend resume parser."""
from
backend.app.services.resume_parser_core
import
main
import
argparse
import
asyncio
import
json
from
pathlib
import
Path
from
backend.app.services.resume_parser
import
parse_resume_path_async
async
def
_parse
(
path
:
Path
)
->
None
:
print
(
json
.
dumps
(
await
parse_resume_path_async
(
path
),
ensure_ascii
=
False
))
def
main
()
->
int
:
parser
=
argparse
.
ArgumentParser
(
description
=
"解析 PDF/DOCX/TXT 简历并输出 JSON。"
)
parser
.
add_argument
(
"file"
,
help
=
"待解析的简历文件路径"
)
args
=
parser
.
parse_args
()
try
:
asyncio
.
run
(
_parse
(
Path
(
args
.
file
)))
return
0
except
Exception
as
exc
:
print
(
json
.
dumps
({
"error"
:
str
(
exc
)},
ensure_ascii
=
False
))
return
1
if
__name__
==
"__main__"
:
raise
SystemExit
(
main
())
backend/tests/test_resume_parser_llm.py
0 → 100644
View file @
8351ae19
import
asyncio
import
pytest
from
backend.app.services.llm
import
llm_resume_fields
from
backend.app.services.resume_parser
import
_merge_parse_result
,
parse_resume_path_async
@
pytest
.
fixture
()
def
llm_disabled
(
monkeypatch
):
"""隔离测试环境:不加载 .env、不配置任何 LLM Key,确保走正则兜底。"""
monkeypatch
.
setenv
(
"RECRUITMENT_SKIP_ENV_FILES"
,
"1"
)
for
key
in
(
"LLM_API_KEY"
,
"DEEPSEEK_API_KEY"
,
"OPENAI_API_KEY"
):
monkeypatch
.
setenv
(
key
,
""
)
import
backend.app.config
as
config
config
.
get_settings
.
cache_clear
()
yield
config
.
get_settings
.
cache_clear
()
def
test_merge_parse_result_prefers_llm_values
():
rule
=
{
"name"
:
"张晓燕"
,
"jobTitle"
:
""
,
"school"
:
"甘肃农业大学"
,
"major"
:
"人力资源管理"
,
"skills"
:
[
"市场营销"
],
"schoolTags"
:
[
"非985/211"
],
}
llm
=
{
"name"
:
""
,
"jobTitle"
:
"销售支持专员"
,
"phone"
:
""
,
"email"
:
""
,
"birthDate"
:
""
,
"age"
:
""
,
"years"
:
""
,
"education"
:
""
,
"school"
:
""
,
"major"
:
"农林经济管理"
,
"city"
:
""
,
"skills"
:
[
"销售支持"
,
"客户管理"
],
}
merged
=
_merge_parse_result
(
rule
,
llm
)
assert
merged
[
"jobTitle"
]
==
"销售支持专员"
assert
merged
[
"major"
]
==
"农林经济管理"
assert
merged
[
"name"
]
==
"张晓燕"
assert
merged
[
"skills"
]
==
[
"销售支持"
,
"客户管理"
,
"市场营销"
]
def
test_merge_parse_result_keeps_rule_when_llm_empty
():
rule
=
{
"name"
:
"张三"
,
"jobTitle"
:
"销售支持专员"
,
"school"
:
"甘肃农业大学"
,
"major"
:
"农林经济管理"
,
"skills"
:
[],
"schoolTags"
:
[
"非985/211"
],
}
llm
=
{
"name"
:
""
,
"jobTitle"
:
""
,
"phone"
:
""
,
"email"
:
""
,
"birthDate"
:
""
,
"age"
:
""
,
"years"
:
""
,
"education"
:
""
,
"school"
:
""
,
"major"
:
""
,
"city"
:
""
,
"skills"
:
[],
}
merged
=
_merge_parse_result
(
rule
,
llm
)
assert
merged
[
"name"
]
==
"张三"
assert
merged
[
"jobTitle"
]
==
"销售支持专员"
assert
merged
[
"major"
]
==
"农林经济管理"
assert
merged
[
"skills"
]
==
[]
def
test_merge_parse_result_recomputes_school_tags
():
merged
=
_merge_parse_result
(
{
"school"
:
"甘肃农业大学"
,
"schoolTags"
:
[
"非985/211"
]},
{
"school"
:
"清华大学"
},
)
assert
merged
[
"school"
]
==
"清华大学"
assert
merged
[
"schoolTags"
]
==
[
"985"
,
"211"
]
def
test_llm_resume_fields_returns_none_without_key
(
llm_disabled
):
assert
asyncio
.
run
(
llm_resume_fields
(
"姓名:张三"
,
{}))
is
None
def
test_async_parse_falls_back_without_llm
(
tmp_path
,
llm_disabled
):
resume
=
tmp_path
/
"李四_高级前端工程师.txt"
resume
.
write_text
(
"姓名:李四
\n
电话:13912345678
\n
邮箱:lisi@example.com
\n
本科 北京大学 软件工程
\n
熟悉 React Python"
,
encoding
=
"utf-8"
,
)
parsed
=
asyncio
.
run
(
parse_resume_path_async
(
resume
))
assert
parsed
[
"name"
]
==
"李四"
assert
parsed
[
"phone"
]
==
"13912345678"
assert
parsed
[
"school"
]
==
"北京大学"
assert
parsed
[
"major"
]
==
"软件工程"
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