简历解析加固:标签字间空格容错(姓 名 :X)、在读学历识别(硕士在读/大二→本科在读)、docx 表格文本按文档顺序提取(修复技能区整块丢失)、词表源补 LLM/大语言模型/Transformers/Prompt Engineering/智能体开发等真实简历高频别名(generate_dataset.py 为词表唯一源,data/ 不入库)、在读/应届年限显示,新增回归测试 2 例,real_jd_500 F1 99.83% 无回退
This commit is contained in:
+47
-7
@@ -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),
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
+5
-2
@@ -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
|
||||
? `工作年限约 <b>${p.years}</b> 年`
|
||||
: (/应届|在读|大[一二三四]/.test(p.raw_masked_text || "") ? "工作年限 <b>在读 / 应届</b>" : "工作年限 <b>未识别</b>");
|
||||
$("#parse-summary").innerHTML =
|
||||
`解析完成(来源:${esc(sourceDesc || "文本")},已自动脱敏):识别技能 <b>${p.skills.length}</b> 项 · ` +
|
||||
`工作年限约 <b>${p.years || 0}</b> 年 · 场景经验 <b>${(p.scenarios || []).join("、") || "未识别"}</b>`;
|
||||
`${yearsHtml} · 场景经验 <b>${(p.scenarios || []).join("、") || "未识别"}</b>`;
|
||||
$("#parse-years").value = matchState.years;
|
||||
renderSkillChips();
|
||||
setMatchStep(2);
|
||||
@@ -1577,7 +1580,7 @@ function renderResumeHighlight(m) {
|
||||
<td class="rd-photo-cell" rowspan="3">${photo}</td>
|
||||
${kv("姓名", p.name)}${kv("性别", p.gender)}
|
||||
</tr>
|
||||
<tr>${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : "")}</tr>
|
||||
<tr>${kv("学历", p.education)}${kv("工作年限", p.years ? p.years + " 年" : (/应届|在读|大[一二三四]/.test(p.raw_masked_text || "") ? "在读 / 应届" : ""))}</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>
|
||||
${secs.map((sec) => `
|
||||
|
||||
+1
-1
@@ -705,7 +705,7 @@
|
||||
<i class="tour-track"><i id="tour-fill"></i></i>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js?v=20260831"></script>
|
||||
<script src="/static/app.js?v=20260831-2"></script>
|
||||
<script src="/static/graph.js?v=20260831"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user