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

fix: 修复简历解析换行粘连与专业误判(主修课程干扰)

parent 9c9e4c37
......@@ -52,12 +52,13 @@ def read_text(path: Path) -> str:
def normalize(text: str) -> str:
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()
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):
......@@ -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 "")
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:
text = compact_chinese(text)
explicit = first_match([
......@@ -337,16 +359,32 @@ def infer_major(text: str) -> str:
return cleaned
school = infer_school(text)
if school and school in text:
segment = text.split(school, 1)[1][:180]
dictionary_major = next((major for major in sorted(COMMON_MAJORS, key=len, reverse=True) if major in segment), "")
after = text.split(school, 1)[1]
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:
return dictionary_major
match = re.search(r"([^\s,,;;。]{2,30}?)(?:专业)?\s*(?:博士|硕士|本科|大专)", segment.strip())
if match:
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 ""
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:
return dictionary_major
return ""
......
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):
......@@ -8,6 +8,8 @@ def test_parse_text_resume(tmp_path):
assert parsed["name"] == "张三"
assert parsed["phone"] == "13812345678"
assert parsed["email"] == "zhangsan@example.com"
assert parsed["school"] == "清华大学"
assert parsed["major"] == "计算机科学与技术"
assert "React" in parsed["skills"]
......@@ -17,3 +19,60 @@ def test_parser_help(capsys):
except SystemExit as exc:
assert exc.code == 0
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细心从每一个小细节开始。\nPersonal 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 = "教育经历\n2017.09—2021.07 甘肃农业大学 农林经济管理 本科\n主修课程:市场营销、人力资源管理"
assert infer_major(text) == "农林经济管理"
def test_major_on_line_after_date_range():
text = "教育经历\n甘肃农业大学\n2017.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 = "教育经历\n2017.09—2021.07 甘肃农业大学\n主修课程:市场营销、人力资源管理"
assert infer_major(text) == ""
......@@ -149,7 +149,8 @@
"工商管理",
"市场营销",
"财务管理",
"会计学"
"会计学",
"农林经济管理"
],
"skills": [
"电力交易",
......@@ -182,4 +183,4 @@
"招聘",
"员工关系"
]
}
\ No newline at end of file
}
......@@ -9,7 +9,7 @@ export function hasProfileValue(value) {
}
export function compactChineseText(text = '') {
return String(text || '').replace(/(?<=[\u4e00-\u9fa5])\s+(?=[\u4e00-\u9fa5])/g, '')
return String(text || '').replace(/(?<=[\u4e00-\u9fa5])[ \t\u3000]+(?=[\u4e00-\u9fa5])/g, '')
}
export function inferPhone(text = '') {
......@@ -165,6 +165,21 @@ export function inferSchool(text = '') {
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\u3000]*/,
''
)
// 含句读/顿号的是句子或课程列表,不当作专业名
if (/[。,、;:]/.test(value)) return ''
value = value.replace(/(?:(|\(|\s)?(?:本科|硕士|博士|大专|专业)(?:)|\))?$/, '').trim()
const match = value.match(/^([\u4e00-\u9fa5A-Za-z0-9()()·\-]{2,30})$/)
return match ? match[1] : ''
}
export function inferMajor(text = '') {
const source = compactChineseText(text)
const explicit = source.match(/(?:专业|所学专业)[::\s]+([\u4e00-\u9fa5A-Za-z0-9()()·\-]{2,30})/)
......@@ -179,9 +194,23 @@ export function inferMajor(text = '') {
/(?:教育背景|教育经历|学习经历)[\s\S]{0,500}?(?:工作经历|项目经历|实习经历|技能|证书|自我评价|$)/
)?.[0] || ''
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)) ||
[...commonMajors].sort((a, b) => b.length - a.length).find((major) => educationBlock.includes(major))
// 优先从学校紧邻行取专业(同行剩余/后一行,跳过日期行),避免误扫进"主修课程"把课程名当成专业
if (schoolIndex >= 0) {
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
const educationLine = source.match(
/(?:大学|学院)\s+([\u4e00-\u9fa5A-Za-z0-9()()·\-]{2,30}?)(?:专业)?\s+(?:博士|硕士|本科|大专)/
......@@ -196,7 +225,11 @@ export function inferMajor(text = '') {
const major = segment.trim().match(/([^\s,,;;。]{2,30}?)(?:专业)?\s*(?:博士|硕士|本科|大专)/)
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 = '') {
......
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