diff --git a/app/match/resume.py b/app/match/resume.py index 6815041..1e175ac 100644 --- a/app/match/resume.py +++ b/app/match/resume.py @@ -18,13 +18,17 @@ logger = logging.getLogger(__name__) _PHONE_RE = re.compile(r"1[3-9]\d{9}") _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") -_NAME_RE = re.compile(r"姓名[::]\s*([一-龥]{2,4})") +# 标签字间可含空格/全角空格(PDF/Word 排版常见:"姓 名 :孙千越") +_NAME_RE = re.compile(r"姓\s*名\s*[::]\s*([一-龥]{2,4})") # 简历常见写法:首行裸姓名(无"姓名:"前缀),如" 王伟航 " _BARE_NAME_RE = re.compile(r"^\s*([一-龥]{2,4})\s*$", re.MULTILINE) _YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验") -_GENDER_RE = re.compile(r"性别[::]?\s*(男|女)") +_GENDER_RE = re.compile(r"性\s*别\s*[::]?\s*(男|女)") _EDU_RE = re.compile(r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)") _EDU_MAP = {"专硕": "硕士", "学硕": "硕士", "研究生": "硕士"} +# 在读学历:「硕士在读」或「大二/大三」这类年级写法 → 本科(在读) +_EDU_READING_RE = re.compile(r"(本科|硕士|博士|研究生|MBA)\s*在读") +_GRADE_RE = re.compile(r"大[一二三四]") _ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})") # 常见简历分节标题(整行匹配,可带冒号) @@ -48,6 +52,17 @@ def _extract_name(text: str) -> str: return "" +def _extract_education(text: str) -> str: + """学历:优先识别在读("硕士在读"/"大二"年级写法),再匹配学位词。""" + rm = _EDU_READING_RE.search(text) + if rm: + return f"{_EDU_MAP.get(rm.group(1), rm.group(1))}(在读)" + if _GRADE_RE.search(text): + return "本科(在读)" + m = _EDU_RE.search(text) + return _EDU_MAP.get(m.group(1), m.group(1)) if m else "" + + def mask_text(text: str) -> str: """脱敏:手机、邮箱、姓名(含首行裸姓名)、住址打码。""" text = _PHONE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-2:], text) @@ -110,6 +125,34 @@ def extract_photo_b64(path: str | Path) -> str: return "" +def _docx_text(path: Path) -> str: + """按文档顺序提取 docx 文本:段落 + 表格(含嵌套表格单元格)。 + + 简历模板普遍用无边框表格排版个人信息/技能区,只读 paragraphs 会整块丢失。 + """ + from docx import Document # python-docx,requirements 已声明 + from docx.table import Table + from docx.text.paragraph import Paragraph + + doc = Document(str(path)) + parts: list[str] = [] + + def walk_table(tbl) -> None: + for row in tbl.rows: + for cell in row.cells: + if cell.text.strip(): + parts.append(cell.text) + for nested in cell.tables: + walk_table(nested) + + for child in doc.element.body.iterchildren(): + if child.tag.endswith("}p"): + parts.append(Paragraph(child, doc).text) + elif child.tag.endswith("}tbl"): + walk_table(Table(child, doc)) + return "\n".join(parts) + + def read_resume_file(path: str | Path) -> str: """按扩展名读取简历文件文本。""" path = Path(path) @@ -117,9 +160,7 @@ def read_resume_file(path: str | Path) -> str: if suffix == ".txt": return path.read_text(encoding="utf-8") if suffix == ".docx": - from docx import Document # python-docx,requirements 已声明 - doc = Document(str(path)) - return "\n".join(p.text for p in doc.paragraphs) + return _docx_text(path) if suffix == ".pdf": try: from pypdf import PdfReader @@ -147,7 +188,6 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None, email_m = _EMAIL_RE.search(text) years_m = _YEARS_RE.search(text) gender_m = _GENDER_RE.search(text) - edu_m = _EDU_RE.search(text) addr_m = _ADDR_RE.search(text) masked = mask_text(text) return ResumeProfile( @@ -159,7 +199,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None, scenarios=matcher.find_scenarios(text), raw_masked_text=masked, gender=gender_m.group(1) if gender_m else "", - education=_EDU_MAP.get(edu_m.group(1), edu_m.group(1)) if edu_m else "", + education=_extract_education(text), address=_mask_addr(addr_m.group(1).strip()) if addr_m else "", photo_b64=photo_b64, sections=split_sections(masked), diff --git a/scripts/generate_dataset.py b/scripts/generate_dataset.py index 2cec374..e2338da 100644 --- a/scripts/generate_dataset.py +++ b/scripts/generate_dataset.py @@ -84,7 +84,7 @@ SKILLS = [ ("数据挖掘", "大数据", [], "L2", ["Python", "SQL"]), # AI 与算法 ("机器学习", "AI算法", ["机器学习算法"], "L2", ["Python"]), - ("深度学习", "AI算法", [], "L3", ["机器学习"]), + ("深度学习", "AI算法", ["Transformers", "Transformer"], "L3", ["机器学习"]), ("PyTorch", "AI算法", [], "L2", ["Python", "深度学习"]), ("TensorFlow", "AI算法", [], "L2", ["Python", "深度学习"]), ("Keras", "AI算法", [], "L2", ["TensorFlow"]), @@ -95,9 +95,12 @@ SKILLS = [ ("OpenCV", "AI算法", [], "L2", ["Python"]), ("OCR", "AI算法", ["文字识别"], "L2", ["计算机视觉"]), ("语音识别", "AI算法", [], "L3", ["深度学习"]), ("推荐算法", "AI算法", ["推荐系统"], "L3", ["机器学习"]), ("强化学习", "AI算法", [], "L3", ["深度学习"]), ("知识图谱", "AI算法", [], "L3", ["NLP"]), - ("大模型应用开发", "AI算法", ["LLM应用开发", "大模型应用", "大语言模型应用"], "L3", ["Python", "NLP"]), + ("大模型应用开发", "AI算法", + ["LLM应用开发", "大模型应用", "大语言模型应用", "LLM", "大语言模型", "大模型", "LLM推理", "LLM评测", "大模型推理", "大语言模型应用开发"], + "L3", ["Python", "NLP"]), ("RAG", "AI算法", ["检索增强生成"], "L3", ["大模型应用开发", "向量数据库"]), - ("提示词工程", "AI算法", ["Prompt工程"], "L2", ["大模型应用开发"]), + ("提示词工程", "AI算法", ["Prompt工程", "Prompt Engineering", "提示词设计"], "L2", ["大模型应用开发"]), + ("智能体开发", "AI算法", ["Agent开发", "多智能体", "Multi-Agent", "智能体", "Agent", "AI Agent"], "L3", ["大模型应用开发"]), ("向量数据库", "AI算法", ["向量库"], "L2", ["数据库基础"]), ("LangChain", "AI算法", [], "L2", ["大模型应用开发"]), ("模型微调", "AI算法", ["Fine-tuning", "SFT"], "L3", ["深度学习", "PyTorch"]), diff --git a/tests/test_match.py b/tests/test_match.py index b9aa03a..07027b6 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -107,6 +107,27 @@ def test_resume_bare_name_and_edu_alias(): assert "Python" in p.skills +def test_resume_spaced_labels_and_reading_edu(): + """PDF/Word 排版常见"姓 名 :孙千越"标签字间带空格;年级写法"大二"推断本科在读。""" + from app.match.resume import parse_resume + text = ("姓 名 :孙千越\n学 校:东北财经大学 出生年月:2007 年 12 月 23 日\n" + "年 级:大二 电子邮箱:qianyuesun@outlook.com\n" + "技术能力:Python、Transformers、LLM 推理与评测、RAG、Prompt Engineering、" + "Multi-Agent Workflow。") + p = parse_resume(text) + assert p.name == "孙**" and "孙千越" not in p.raw_masked_text # 空格分隔标签 + 脱敏 + assert p.education == "本科(在读)" # 年级写法 → 在读学历 + for s in ["大模型应用开发", "智能体开发", "深度学习", "提示词工程", "RAG", "Python"]: + assert s in p.skills, s # 新词表别名命中 + + +def test_resume_reading_degree_explicit(): + """明确"硕士在读"写法:保留在读标记,不降级为已毕业学位。""" + from app.match.resume import parse_resume + p = parse_resume("姓名:张三\n学历:硕士在读\n技能:Python") + assert p.education == "硕士(在读)" + + def test_learning_path_stage_chunking(): """同层技能超 3 个时拆成多个连续阶段;拓扑序不变。""" stages = topological_stages(["Linux", "Docker", "Kubernetes", "Python", "Java", "Go", "MySQL"], diff --git a/webui/app.js b/webui/app.js index c0b64a3..587a3b1 100644 --- a/webui/app.js +++ b/webui/app.js @@ -1388,9 +1388,12 @@ function applyParsedProfile(p, sourceDesc) { matchState.skills = [...p.skills]; matchState.years = p.years || 0; $("#resume-text").value = p.raw_masked_text || ""; + const yearsHtml = p.years + ? `工作年限约 ${p.years} 年` + : (/应届|在读|大[一二三四]/.test(p.raw_masked_text || "") ? "工作年限 在读 / 应届" : "工作年限 未识别"); $("#parse-summary").innerHTML = `解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 ${p.skills.length} 项 · ` + - `工作年限约 ${p.years || 0} 年 · 场景经验 ${(p.scenarios || []).join("、") || "未识别"}`; + `${yearsHtml} · 场景经验 ${(p.scenarios || []).join("、") || "未识别"}`; $("#parse-years").value = matchState.years; renderSkillChips(); setMatchStep(2); @@ -1577,7 +1580,7 @@ function renderResumeHighlight(m) {