简历学历/年限机制完善:学位词同行'至今'推断在读(专硕…2025.09-至今→硕士(在读))、无'X年经验'时按实习/工作/项目时间段累计兜底(教育经历不计入)、年限显示优先级(明确工龄>在读/应届>区间估计),新增回归测试 2 例

This commit is contained in:
FinStab
2026-09-01 18:09:17 +08:00
parent 896ac10f12
commit fc200a7592
4 changed files with 71 additions and 8 deletions
+38 -2
View File
@@ -23,11 +23,17 @@ _NAME_RE = re.compile(r"姓\s*名\s*[::]\s*([一-龥]{2,4})")
# 简历常见写法:首行裸姓名(无"姓名:"前缀),如" 王伟航 " # 简历常见写法:首行裸姓名(无"姓名:"前缀),如" 王伟航 "
_BARE_NAME_RE = re.compile(r"^\s*([一-龥]{2,4})\s*$", re.MULTILINE) _BARE_NAME_RE = re.compile(r"^\s*([一-龥]{2,4})\s*$", re.MULTILINE)
_YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验") _YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验")
# 时间段兜底:"2023.07-2023.08" / "2021.09-2025.6" / "2025.12-至今"
_PERIOD_RE = re.compile(
r"(20\d{2})\s*[./年]\s*(\d{1,2})\s*月?\s*[-–—~]+\s*(?:(20\d{2})\s*[./年]\s*(\d{1,2})\s*月?|至今)")
_GENDER_RE = re.compile(r"性\s*别\s*[::]?\s*(男|女)") _GENDER_RE = re.compile(r"性\s*别\s*[::]?\s*(男|女)")
_EDU_RE = re.compile(r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)") _EDU_RE = re.compile(r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)")
_EDU_MAP = {"专硕": "硕士", "学硕": "硕士", "研究生": "硕士"} _EDU_MAP = {"专硕": "硕士", "学硕": "硕士", "研究生": "硕士"}
# 在读学历:「硕士在读」或「大二/大三」这类年级写法 → 本科(在读) # 在读学历:「硕士在读」或「大二/大三」这类年级写法 → 本科(在读)
_EDU_READING_RE = re.compile(r"(本科|硕士|博士|研究生|MBA)\s*在读") _EDU_READING_RE = re.compile(r"(本科|硕士|博士|研究生|MBA)\s*在读")
# 学位词同行出现"至今"(教育经历未结束的常见写法):金融科技(专硕)… 2025.09-至今 → 在读
_EDU_ONGOING_RE = re.compile(
r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)[^\n]{0,30}?至今")
_GRADE_RE = re.compile(r"大[一二三四]") _GRADE_RE = re.compile(r"大[一二三四]")
_ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})") _ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})")
@@ -53,10 +59,13 @@ def _extract_name(text: str) -> str:
def _extract_education(text: str) -> str: def _extract_education(text: str) -> str:
"""学历:优先识别在读("硕士在读"/"大二"年级写法),再匹配学位词。""" """学历:优先识别在读("硕士在读" / 学位词同行"至今" / "大二"年级写法),再匹配学位词。"""
rm = _EDU_READING_RE.search(text) rm = _EDU_READING_RE.search(text)
if rm: if rm:
return f"{_EDU_MAP.get(rm.group(1), rm.group(1))}(在读)" return f"{_EDU_MAP.get(rm.group(1), rm.group(1))}(在读)"
om = _EDU_ONGOING_RE.search(text)
if om:
return f"{_EDU_MAP.get(om.group(1), om.group(1))}(在读)"
if _GRADE_RE.search(text): if _GRADE_RE.search(text):
return "本科(在读)" return "本科(在读)"
m = _EDU_RE.search(text) m = _EDU_RE.search(text)
@@ -178,6 +187,33 @@ def _mask_addr(addr: str) -> str:
return addr[:6] + "***" if len(addr) > 6 else addr return addr[:6] + "***" if len(addr) > 6 else addr
def _estimate_years(text: str) -> float:
"""工作年限兜底估计:无"X年经验"写法时,累加工作/实习/项目分节中的时间段。
教育经历不计入工龄;无分节时剔除教育相关行后全文兜底。
单段封顶 36 个月、总计封顶 10 年,防误识别。
"""
import datetime as _dt
work_titles = {"工作经历", "工作经验", "实习经历", "项目经历", "项目经验"}
secs = split_sections(text)
body = "\n".join(s["text"] for s in secs if s["title"] in work_titles)
if not body.strip():
body = "\n".join(ln for ln in text.splitlines()
if not re.search(r"大学|学院|学校|教育|学历|学位|GPA", ln, re.IGNORECASE))
now = _dt.date.today()
months = 0
for m in _PERIOD_RE.finditer(body):
y1, m1 = int(m.group(1)), int(m.group(2))
if m.group(3):
y2, m2 = int(m.group(3)), int(m.group(4))
else:
y2, m2 = now.year, now.month
dm = (y2 - y1) * 12 + (m2 - m1) + 1
if 0 < dm <= 600: # 超 50 年的区间视为误识别
months += min(dm, 36)
return round(min(months, 120) / 12, 1)
def parse_resume(text: str, matcher: SkillMatcher | None = None, def parse_resume(text: str, matcher: SkillMatcher | None = None,
photo_b64: str = "") -> ResumeProfile: photo_b64: str = "") -> ResumeProfile:
"""从简历文本解析脱敏画像(含结构化基本信息与分节)。""" """从简历文本解析脱敏画像(含结构化基本信息与分节)。"""
@@ -194,7 +230,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None,
name=(name[0] + "*" * (len(name) - 1)) if name else "", name=(name[0] + "*" * (len(name) - 1)) if name else "",
phone=(phone_m.group(0)[:3] + "****" + phone_m.group(0)[-2:]) if phone_m else "", phone=(phone_m.group(0)[:3] + "****" + phone_m.group(0)[-2:]) if phone_m else "",
email=(email_m.group(0)[0] + "***@" + email_m.group(0).split("@")[1]) if email_m else "", email=(email_m.group(0)[0] + "***@" + email_m.group(0).split("@")[1]) if email_m else "",
years=float(years_m.group(1)) if years_m else 0.0, years=float(years_m.group(1)) if years_m else _estimate_years(text),
skills=matcher.find_names(text), skills=matcher.find_names(text),
scenarios=matcher.find_scenarios(text), scenarios=matcher.find_scenarios(text),
raw_masked_text=masked, raw_masked_text=masked,
+21 -2
View File
@@ -94,7 +94,7 @@ def test_resume_parse_skills_and_years():
def test_resume_bare_name_and_edu_alias(): def test_resume_bare_name_and_edu_alias():
"""真实简历排版:首行裸姓名(无"姓名:"前缀)可识别并脱敏;专硕归一为硕士。""" """真实简历排版:首行裸姓名(无"姓名:"前缀)可识别并脱敏;专硕归一;"至今"推断在读。"""
from app.match.resume import parse_resume from app.match.resume import parse_resume
text = (" 王伟航 \n出生年月:2003年10月 现居地:辽宁大连\n" text = (" 王伟航 \n出生年月:2003年10月 现居地:辽宁大连\n"
"联系电话:19293819303 电子邮件:wwh@example.com\n" "联系电话:19293819303 电子邮件:wwh@example.com\n"
@@ -102,7 +102,7 @@ def test_resume_bare_name_and_edu_alias():
"职业技能:熟练掌握Python、Hadoop等数据分析技能。") "职业技能:熟练掌握Python、Hadoop等数据分析技能。")
p = parse_resume(text) p = parse_resume(text)
assert p.name == "王**" and "王伟航" not in p.raw_masked_text # 裸姓名识别+脱敏 assert p.name == "王**" and "王伟航" not in p.raw_masked_text # 裸姓名识别+脱敏
assert p.education == "硕士" # 专硕→硕士 assert p.education == "硕士(在读)" # 专硕→硕士;同行"至今"→在读
assert p.address.startswith("辽宁大连") assert p.address.startswith("辽宁大连")
assert "Python" in p.skills assert "Python" in p.skills
@@ -128,6 +128,25 @@ def test_resume_reading_degree_explicit():
assert p.education == "硕士(在读)" assert p.education == "硕士(在读)"
def test_resume_years_estimate_from_periods():
"""无"X年经验"时按实习/工作/项目时间段累计兜底;教育经历不计入工龄。"""
from app.match.resume import parse_resume
text = ("姓名:王伟航\n"
"教育经历\n东北财经大学 金融科技(专硕) 2025.09-至今\n"
"实习经历\n某研究院 2025.12-2026.02\n某中心 2023.07-2023.08\n某公司 2024.07-2024.09\n"
"职业技能:Python")
p = parse_resume(text)
assert p.education == "硕士(在读)"
assert p.years == 0.7 # 3+2+3=8 个月实习
def test_resume_years_explicit_wins():
"""明确"X年经验"优先于时间段估计。"""
from app.match.resume import parse_resume
p = parse_resume("姓名:张三\n工作经历\n某公司 2020.01-至今\n5年开发经验,熟悉Python。")
assert p.years == 5.0
def test_learning_path_stage_chunking(): def test_learning_path_stage_chunking():
"""同层技能超 3 个时拆成多个连续阶段;拓扑序不变。""" """同层技能超 3 个时拆成多个连续阶段;拓扑序不变。"""
stages = topological_stages(["Linux", "Docker", "Kubernetes", "Python", "Java", "Go", "MySQL"], stages = topological_stages(["Linux", "Docker", "Kubernetes", "Python", "Java", "Go", "MySQL"],
+11 -3
View File
@@ -1388,9 +1388,12 @@ function applyParsedProfile(p, sourceDesc) {
matchState.skills = [...p.skills]; matchState.skills = [...p.skills];
matchState.years = p.years || 0; matchState.years = p.years || 0;
$("#resume-text").value = p.raw_masked_text || ""; $("#resume-text").value = p.raw_masked_text || "";
const yearsHtml = p.years const studentish = (p.education || "").includes("在读")
|| /应届|在读|大[一二三四]/.test(p.raw_masked_text || "");
const yearsHtml = p.years >= 1
? `工作年限约 <b>${p.years}</b> 年` ? `工作年限约 <b>${p.years}</b> 年`
: (/应届|在读|大[一二三四]/.test(p.raw_masked_text || "") ? "工作年限 <b>在读 / 应届</b>" : "工作年限 <b>未识别</b>"); : (studentish ? "工作年限 <b>在读 / 应届</b>"
: (p.years ? `工作年限约 <b>${p.years}</b> 年(实习/项目累计)` : "工作年限 <b>未识别</b>"));
$("#parse-summary").innerHTML = $("#parse-summary").innerHTML =
`解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 <b>${p.skills.length}</b> 项 · ` + `解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 <b>${p.skills.length}</b> 项 · ` +
`${yearsHtml} · 场景经验 <b>${(p.scenarios || []).join("、") || "未识别"}</b>`; `${yearsHtml} · 场景经验 <b>${(p.scenarios || []).join("、") || "未识别"}</b>`;
@@ -1572,6 +1575,11 @@ function renderResumeHighlight(m) {
? `<img class="rd-photo" src="${p.photo_b64}" alt="证件照">` ? `<img class="rd-photo" src="${p.photo_b64}" alt="证件照">`
: `<div class="rd-photo rd-photo-empty"><svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/></svg><span>无照片</span></div>`; : `<div class="rd-photo rd-photo-empty"><svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/></svg><span>无照片</span></div>`;
const kv = (k, v) => `<td class="rd-k">${k}</td><td class="rd-v">${esc(v || "—")}</td>`; const kv = (k, v) => `<td class="rd-k">${k}</td><td class="rd-v">${esc(v || "—")}</td>`;
/* 年限显示:明确工龄优先;在读学历/应届文本其次;再退到实习项目区间估计值 */
const studentish = (p.education || "").includes("在读")
|| /应届|在读|大[一二三四]/.test(p.raw_masked_text || "");
const yearsText = p.years >= 1 ? p.years + " 年"
: (studentish ? "在读 / 应届" : (p.years ? `约 ${p.years} 年(实习/项目累计)` : ""));
const secs = (p.sections && p.sections.length) const secs = (p.sections && p.sections.length)
? p.sections : [{ title: "简历全文", text: p.raw_masked_text }]; ? p.sections : [{ title: "简历全文", text: p.raw_masked_text }];
box.innerHTML = ` box.innerHTML = `
@@ -1580,7 +1588,7 @@ function renderResumeHighlight(m) {
<td class="rd-photo-cell" rowspan="3">${photo}</td> <td class="rd-photo-cell" rowspan="3">${photo}</td>
${kv("姓名", p.name)}${kv("性别", p.gender)} ${kv("姓名", p.name)}${kv("性别", p.gender)}
</tr> </tr>
<tr>${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : (/应届|在读|大[一二三四]/.test(p.raw_masked_text || "") ? "在读 / 应届" : ""))}</tr> <tr>${kv("学历", p.education)}${kv("工作年限", yearsText)}</tr>
<tr>${kv("手机", p.phone)}${kv("邮箱", p.email)}</tr> <tr>${kv("手机", p.phone)}${kv("邮箱", p.email)}</tr>
<tr><td class="rd-k">家庭住址</td><td class="rd-v" colspan="4">${esc(p.address || "—")}</td></tr> <tr><td class="rd-k">家庭住址</td><td class="rd-v" colspan="4">${esc(p.address || "—")}</td></tr>
${secs.map((sec) => ` ${secs.map((sec) => `
+1 -1
View File
@@ -705,7 +705,7 @@
<i class="tour-track"><i id="tour-fill"></i></i> <i class="tour-track"><i id="tour-fill"></i></i>
</div> </div>
<script src="/static/app.js?v=20260831-2"></script> <script src="/static/app.js?v=20260901"></script>
<script src="/static/graph.js?v=20260831"></script> <script src="/static/graph.js?v=20260831"></script>
</body> </body>
</html> </html>