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
ef75212d
Commit
ef75212d
authored
Sep 01, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: 修复简历解析换行粘连与专业误判(主修课程干扰)
parent
9c9e4c37
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
145 additions
and
14 deletions
+145
-14
resume_parser_core.py
backend/app/services/resume_parser_core.py
+44
-6
test_resume_parser.py
backend/tests/test_resume_parser.py
+60
-1
resume-dictionaries.json
shared/resume-dictionaries.json
+3
-2
inference.js
vue-app/src/utils/inference.js
+38
-5
No files found.
backend/app/services/resume_parser_core.py
View file @
ef75212d
...
@@ -52,12 +52,13 @@ def read_text(path: Path) -> str:
...
@@ -52,12 +52,13 @@ def read_text(path: Path) -> str:
def
normalize
(
text
:
str
)
->
str
:
def
normalize
(
text
:
str
)
->
str
:
text
=
text
.
replace
(
"
\r
"
,
"
\n
"
)
text
=
text
.
replace
(
"
\r
"
,
"
\n
"
)
text
=
re
.
sub
(
r"(?<=[一-龥])\s+(?=[一-龥])"
,
""
,
text
)
# 只压缩汉字之间的横向空白(空格/制表符/全角空格),保留换行,避免把相邻行粘连
text
=
re
.
sub
(
r"(?<=[一-龥])[ \t\u3000]+(?=[一-龥])"
,
""
,
text
)
return
re
.
sub
(
r"\n{3,}"
,
"
\n\n
"
,
text
)
.
strip
()
return
re
.
sub
(
r"\n{3,}"
,
"
\n\n
"
,
text
)
.
strip
()
def
compact_chinese
(
text
:
str
)
->
str
:
def
compact_chinese
(
text
:
str
)
->
str
:
return
re
.
sub
(
r"(?<=[一-龥])
\s
+(?=[一-龥])"
,
""
,
text
or
""
)
return
re
.
sub
(
r"(?<=[一-龥])
[ \t\u3000]
+(?=[一-龥])"
,
""
,
text
or
""
)
def
first_match
(
patterns
,
text
):
def
first_match
(
patterns
,
text
):
...
@@ -324,6 +325,27 @@ def infer_school(text: str) -> str:
...
@@ -324,6 +325,27 @@ def infer_school(text: str) -> str:
return
next
((
school
for
school
in
candidates
if
school
.
endswith
(
"大学"
)),
candidates
[
0
]
if
candidates
else
""
)
return
next
((
school
for
school
in
candidates
if
school
.
endswith
(
"大学"
)),
candidates
[
0
]
if
candidates
else
""
)
def
_major_from_adjacent_line
(
line
:
str
)
->
str
:
"""从学校紧邻的一行里提取专业名;不是专业行时返回空串。"""
line
=
line
.
strip
()
if
not
line
or
re
.
match
(
r"主修|主要课程|课程|专业课|选修"
,
line
):
return
""
# 去掉行首时间范围,如 2017.09—2021.07 / 2017.09-2021.07 / 2017.09 / 2017年
line
=
re
.
sub
(
r"^(?:\d{4}\s*[年./\-]\s*\d{1,2}\s*(?:—|-|~|至|到)\s*\d{4}\s*[年./\-]?\s*\d{0,2}\s*[年]?"
r"|\d{4}\s*[年./\-]\s*\d{1,2}\s*[年]?"
r"|\d{4}\s*年)[\s\u3000]*"
,
""
,
line
,
)
# 含句读/顿号的是句子或课程列表,不当作专业名
if
re
.
search
(
r"[。,、;:]"
,
line
):
return
""
line
=
re
.
sub
(
r"(?:(|\(|\s)?(?:本科|硕士|博士|大专|专业)(?:)|\))?$"
,
""
,
line
)
.
strip
()
match
=
re
.
match
(
r"^([一-龥A-Za-z0-9()()·\-]{2,30})$"
,
line
)
return
match
.
group
(
1
)
if
match
else
""
def
infer_major
(
text
:
str
)
->
str
:
def
infer_major
(
text
:
str
)
->
str
:
text
=
compact_chinese
(
text
)
text
=
compact_chinese
(
text
)
explicit
=
first_match
([
explicit
=
first_match
([
...
@@ -337,16 +359,32 @@ def infer_major(text: str) -> str:
...
@@ -337,16 +359,32 @@ def infer_major(text: str) -> str:
return
cleaned
return
cleaned
school
=
infer_school
(
text
)
school
=
infer_school
(
text
)
if
school
and
school
in
text
:
if
school
and
school
in
text
:
segment
=
text
.
split
(
school
,
1
)[
1
][:
180
]
after
=
text
.
split
(
school
,
1
)[
1
]
dictionary_major
=
next
((
major
for
major
in
sorted
(
COMMON_MAJORS
,
key
=
len
,
reverse
=
True
)
if
major
in
segment
),
""
)
segment
=
after
[:
180
]
# 优先从学校紧邻行取专业(同行剩余/后一行,跳过日期行),
# 避免误扫进"主修课程"把课程名当成专业
for
line
in
[
part
.
strip
()
for
part
in
after
.
splitlines
()
if
part
.
strip
()][:
3
]:
adjacent
=
_major_from_adjacent_line
(
line
)
if
adjacent
:
return
adjacent
# 排除"主修课程/课程"之后的内容再按词典匹配
pre_course
=
re
.
split
(
r"(?:主修课程|主要课程|主修|课程|专业课|选修)"
,
segment
,
maxsplit
=
1
)[
0
]
dictionary_major
=
next
(
(
major
for
major
in
sorted
(
COMMON_MAJORS
,
key
=
len
,
reverse
=
True
)
if
major
in
pre_course
),
""
)
if
dictionary_major
:
if
dictionary_major
:
return
dictionary_major
return
dictionary_major
match
=
re
.
search
(
r"([^\s,,;;。]{2,30}?)(?:专业)?\s*(?:博士|硕士|本科|大专)"
,
segment
.
strip
())
match
=
re
.
search
(
r"([^\s,,;;。]{2,30}?)(?:专业)?\s*(?:博士|硕士|本科|大专)"
,
segment
.
strip
())
if
match
:
if
match
:
return
re
.
sub
(
r"专业$"
,
""
,
match
.
group
(
1
))
.
strip
()
return
re
.
sub
(
r"专业$"
,
""
,
match
.
group
(
1
))
.
strip
()
education_block_match
=
re
.
search
(
r"(?:教育背景|教育经历|学习经历)[\s\S]{0,500}?(?:工作经历|项目经历|实习经历|技能|证书|自我评价|$)"
,
text
)
education_block_match
=
re
.
search
(
r"(?:教育背景|教育经历|学习经历)[\s\S]{0,500}?(?:工作经历|项目经历|实习经历|技能|证书|自我评价|$)"
,
text
)
education_block
=
education_block_match
.
group
(
0
)
if
education_block_match
else
""
education_block
=
education_block_match
.
group
(
0
)
if
education_block_match
else
""
dictionary_major
=
next
((
major
for
major
in
sorted
(
COMMON_MAJORS
,
key
=
len
,
reverse
=
True
)
if
major
in
education_block
),
""
)
pre_course_block
=
re
.
split
(
r"(?:主修课程|主要课程|主修|课程|专业课|选修)"
,
education_block
,
maxsplit
=
1
)[
0
]
dictionary_major
=
next
(
(
major
for
major
in
sorted
(
COMMON_MAJORS
,
key
=
len
,
reverse
=
True
)
if
major
in
pre_course_block
),
""
)
if
dictionary_major
:
if
dictionary_major
:
return
dictionary_major
return
dictionary_major
return
""
return
""
...
...
backend/tests/test_resume_parser.py
View file @
ef75212d
from
backend.app.services.resume_parser_core
import
main
,
parse_resume
from
backend.app.services.resume_parser_core
import
infer_major
,
main
,
normalize
,
parse_resume
def
test_parse_text_resume
(
tmp_path
):
def
test_parse_text_resume
(
tmp_path
):
...
@@ -8,6 +8,8 @@ def test_parse_text_resume(tmp_path):
...
@@ -8,6 +8,8 @@ def test_parse_text_resume(tmp_path):
assert
parsed
[
"name"
]
==
"张三"
assert
parsed
[
"name"
]
==
"张三"
assert
parsed
[
"phone"
]
==
"13812345678"
assert
parsed
[
"phone"
]
==
"13812345678"
assert
parsed
[
"email"
]
==
"zhangsan@example.com"
assert
parsed
[
"email"
]
==
"zhangsan@example.com"
assert
parsed
[
"school"
]
==
"清华大学"
assert
parsed
[
"major"
]
==
"计算机科学与技术"
assert
"React"
in
parsed
[
"skills"
]
assert
"React"
in
parsed
[
"skills"
]
...
@@ -17,3 +19,60 @@ def test_parser_help(capsys):
...
@@ -17,3 +19,60 @@ def test_parser_help(capsys):
except
SystemExit
as
exc
:
except
SystemExit
as
exc
:
assert
exc
.
code
==
0
assert
exc
.
code
==
0
assert
"解析 PDF/DOCX/TXT 简历"
in
capsys
.
readouterr
()
.
out
assert
"解析 PDF/DOCX/TXT 简历"
in
capsys
.
readouterr
()
.
out
def
test_normalize_preserves_line_breaks_between_chinese_lines
():
text
=
"基本信息
\n
姓 名: 薛庆霞
\n
民 族 : 汉
\n
电 话: 15110312431"
normalized
=
normalize
(
text
)
assert
"基本信息
\n
姓名:"
in
normalized
assert
"薛庆霞
\n
民族"
in
normalized
def
test_parse_name_not_merged_with_next_line
(
tmp_path
):
resume
=
tmp_path
/
"薛庆霞.txt"
resume
.
write_text
(
"个人简历
\n
细心从每一个小细节开始。
\n
Personal resume
\n
基本信息
\n
"
"姓 名: 薛庆霞
\n
民 族 : 汉
\n
电 话: 15110312431
\n
"
"邮 箱: 1176053788@qq.com
\n
住 址: 山西省太原市万柏林区
\n
出生年月: 1993.04.30
\n
"
"身 高: 170cm
\n
政治面貌 :群众
\n
毕业院校: 山西警察学院
\n
籍 贯: 山西太原
\n
教育背景"
,
encoding
=
"utf-8"
,
)
parsed
=
parse_resume
(
resume
)
assert
parsed
[
"name"
]
==
"薛庆霞"
assert
parsed
[
"phone"
]
==
"15110312431"
assert
parsed
[
"email"
]
==
"1176053788@qq.com"
def
test_major_prefers_adjacent_line_over_course_names
(
tmp_path
):
resume
=
tmp_path
/
"张晓燕.txt"
resume
.
write_text
(
"张晓燕
\n
性别:女年龄:28
\n
电话:18734915261 邮箱:1822742034@qq.com
\n
销售支持专员
\n
教育经历
\n
"
"2017.09—2021.07 甘肃农业大学
\n
农林经济管理
\n
"
"主修课程:市场营销、管理学原理、人力资源管理、企业经营战略、财务管理、会计学原理、区域经济学、发展经济
\n
"
"学等。专业基础扎实,系统掌握市场营销、企业管理、经济分析相关知识,具备销售业务逻辑、客户管理及商务协作
\n
"
"理论功底。"
,
encoding
=
"utf-8"
,
)
parsed
=
parse_resume
(
resume
)
assert
parsed
[
"school"
]
==
"甘肃农业大学"
assert
parsed
[
"major"
]
==
"农林经济管理"
def
test_major_same_line_after_school
():
text
=
"教育经历
\n
2017.09—2021.07 甘肃农业大学 农林经济管理 本科
\n
主修课程:市场营销、人力资源管理"
assert
infer_major
(
text
)
==
"农林经济管理"
def
test_major_on_line_after_date_range
():
text
=
"教育经历
\n
甘肃农业大学
\n
2017.09—2021.07
\n
农林经济管理
\n
主修课程:人力资源管理"
assert
infer_major
(
text
)
==
"农林经济管理"
def
test_major_explicit_field_wins
():
text
=
"毕业院校:甘肃农业大学
\n
专业:人力资源管理
\n
主修课程:市场营销"
assert
infer_major
(
text
)
==
"人力资源管理"
def
test_major_course_names_not_picked_when_no_major
():
text
=
"教育经历
\n
2017.09—2021.07 甘肃农业大学
\n
主修课程:市场营销、人力资源管理"
assert
infer_major
(
text
)
==
""
shared/resume-dictionaries.json
View file @
ef75212d
...
@@ -149,7 +149,8 @@
...
@@ -149,7 +149,8 @@
"工商管理"
,
"工商管理"
,
"市场营销"
,
"市场营销"
,
"财务管理"
,
"财务管理"
,
"会计学"
"会计学"
,
"农林经济管理"
],
],
"skills"
:
[
"skills"
:
[
"电力交易"
,
"电力交易"
,
...
...
vue-app/src/utils/inference.js
View file @
ef75212d
...
@@ -9,7 +9,7 @@ export function hasProfileValue(value) {
...
@@ -9,7 +9,7 @@ export function hasProfileValue(value) {
}
}
export
function
compactChineseText
(
text
=
''
)
{
export
function
compactChineseText
(
text
=
''
)
{
return
String
(
text
||
''
).
replace
(
/
(?<
=
[\u
4e00-
\u
9fa5
])
\s
+
(?=[\u
4e00-
\u
9fa5
])
/g
,
''
)
return
String
(
text
||
''
).
replace
(
/
(?<
=
[\u
4e00-
\u
9fa5
])
[
\t\u
3000
]
+
(?=[\u
4e00-
\u
9fa5
])
/g
,
''
)
}
}
export
function
inferPhone
(
text
=
''
)
{
export
function
inferPhone
(
text
=
''
)
{
...
@@ -165,6 +165,21 @@ export function inferSchool(text = '') {
...
@@ -165,6 +165,21 @@ export function inferSchool(text = '') {
return
candidates
.
find
((
school
)
=>
school
.
endsWith
(
'大学'
))
||
candidates
[
0
]
||
''
return
candidates
.
find
((
school
)
=>
school
.
endsWith
(
'大学'
))
||
candidates
[
0
]
||
''
}
}
function
majorFromAdjacentLine
(
line
=
''
)
{
let
value
=
line
.
trim
()
if
(
!
value
||
/^
(
主修|主要课程|课程|专业课|选修
)
/
.
test
(
value
))
return
''
// 去掉行首时间范围,如 2017.09—2021.07 / 2017.09-2021.07 / 2017.09 / 2017年
value
=
value
.
replace
(
/^
(?:\d{4}\s
*
[
年.
/\-]\s
*
\d{1,2}\s
*
(?:
—|-|~|至|到
)\s
*
\d{4}\s
*
[
年.
/\-]?\s
*
\d{0,2}\s
*
[
年
]?
|
\d{4}\s
*
[
年.
/\-]\s
*
\d{1,2}\s
*
[
年
]?
|
\d{4}\s
*年
)[\s\u
3000
]
*/
,
''
)
// 含句读/顿号的是句子或课程列表,不当作专业名
if
(
/
[
。,、;:
]
/
.
test
(
value
))
return
''
value
=
value
.
replace
(
/
(?:
(|
\(
|
\s)?(?:
本科|硕士|博士|大专|专业
)(?:
)|
\))?
$/
,
''
).
trim
()
const
match
=
value
.
match
(
/^
([\u
4e00-
\u
9fa5A-Za-z0-9()()·
\-]{2,30})
$/
)
return
match
?
match
[
1
]
:
''
}
export
function
inferMajor
(
text
=
''
)
{
export
function
inferMajor
(
text
=
''
)
{
const
source
=
compactChineseText
(
text
)
const
source
=
compactChineseText
(
text
)
const
explicit
=
source
.
match
(
/
(?:
专业|所学专业
)[
::
\s]
+
([\u
4e00-
\u
9fa5A-Za-z0-9()()·
\-]{2,30})
/
)
const
explicit
=
source
.
match
(
/
(?:
专业|所学专业
)[
::
\s]
+
([\u
4e00-
\u
9fa5A-Za-z0-9()()·
\-]{2,30})
/
)
...
@@ -179,9 +194,23 @@ export function inferMajor(text = '') {
...
@@ -179,9 +194,23 @@ export function inferMajor(text = '') {
/
(?:
教育背景|教育经历|学习经历
)[\s\S]{0,500}?(?:
工作经历|项目经历|实习经历|技能|证书|自我评价|$
)
/
/
(?:
教育背景|教育经历|学习经历
)[\s\S]{0,500}?(?:
工作经历|项目经历|实习经历|技能|证书|自我评价|$
)
/
)?.[
0
]
||
''
)?.[
0
]
||
''
const
educationSegment
=
schoolIndex
>=
0
?
source
.
slice
(
schoolIndex
,
schoolIndex
+
180
)
:
educationBlock
||
source
const
educationSegment
=
schoolIndex
>=
0
?
source
.
slice
(
schoolIndex
,
schoolIndex
+
180
)
:
educationBlock
||
source
const
dictionaryMajor
=
// 优先从学校紧邻行取专业(同行剩余/后一行,跳过日期行),避免误扫进"主修课程"把课程名当成专业
[...
commonMajors
].
sort
((
a
,
b
)
=>
b
.
length
-
a
.
length
).
find
((
major
)
=>
educationSegment
.
includes
(
major
))
||
if
(
schoolIndex
>=
0
)
{
[...
commonMajors
].
sort
((
a
,
b
)
=>
b
.
length
-
a
.
length
).
find
((
major
)
=>
educationBlock
.
includes
(
major
))
const
after
=
source
.
slice
(
schoolIndex
+
school
.
length
)
const
adjacent
=
after
.
split
(
'
\
n'
)
.
map
((
line
)
=>
line
.
trim
())
.
filter
(
Boolean
)
.
slice
(
0
,
3
)
.
map
(
majorFromAdjacentLine
)
.
find
(
Boolean
)
if
(
adjacent
)
return
adjacent
}
// 排除"主修课程/课程"之后的内容再按词典匹配
const
preCourse
=
educationSegment
.
split
(
/
(?:
主修课程|主要课程|主修|课程|专业课|选修
)
/
)[
0
]
const
dictionaryMajor
=
[...
commonMajors
]
.
sort
((
a
,
b
)
=>
b
.
length
-
a
.
length
)
.
find
((
major
)
=>
preCourse
.
includes
(
major
))
if
(
dictionaryMajor
)
return
dictionaryMajor
if
(
dictionaryMajor
)
return
dictionaryMajor
const
educationLine
=
source
.
match
(
const
educationLine
=
source
.
match
(
/
(?:
大学|学院
)\s
+
([\u
4e00-
\u
9fa5A-Za-z0-9()()·
\-]{2,30}?)(?:
专业
)?\s
+
(?:
博士|硕士|本科|大专
)
/
/
(?:
大学|学院
)\s
+
([\u
4e00-
\u
9fa5A-Za-z0-9()()·
\-]{2,30}?)(?:
专业
)?\s
+
(?:
博士|硕士|本科|大专
)
/
...
@@ -196,7 +225,11 @@ export function inferMajor(text = '') {
...
@@ -196,7 +225,11 @@ export function inferMajor(text = '') {
const
major
=
segment
.
trim
().
match
(
/
([^\s
,,;;。
]{2,30}?)(?:
专业
)?\s
*
(?:
博士|硕士|本科|大专
)
/
)
const
major
=
segment
.
trim
().
match
(
/
([^\s
,,;;。
]{2,30}?)(?:
专业
)?\s
*
(?:
博士|硕士|本科|大专
)
/
)
if
(
major
)
return
major
[
1
].
replace
(
/专业$/g
,
''
).
trim
()
if
(
major
)
return
major
[
1
].
replace
(
/专业$/g
,
''
).
trim
()
}
}
return
''
const
preCourseBlock
=
educationBlock
.
split
(
/
(?:
主修课程|主要课程|主修|课程|专业课|选修
)
/
)[
0
]
const
fallbackMajor
=
[...
commonMajors
]
.
sort
((
a
,
b
)
=>
b
.
length
-
a
.
length
)
.
find
((
major
)
=>
preCourseBlock
.
includes
(
major
))
return
fallbackMajor
||
''
}
}
export
function
schoolTags
(
school
=
''
)
{
export
function
schoolTags
(
school
=
''
)
{
...
...
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