CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"""人岗匹配引擎:技能覆盖率召回 + 四维加权评分 + 差距分析 + 学习路径。
|
||||
|
||||
四维权重默认 必备 50% / 加分 15% / 经验 20% / 场景 15%,可通过 config 覆盖。
|
||||
学习路径:缺失技能按图谱/词表中的前置关系拓扑排序,分阶段输出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.extract.base import load_lexicon
|
||||
from app.models import JobDefinition, MatchResult, ResumeProfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_WEIGHTS = {
|
||||
"required": 0.50,
|
||||
"preferred": 0.15,
|
||||
"experience": 0.20,
|
||||
"scenario": 0.15,
|
||||
}
|
||||
|
||||
|
||||
def _coverage(have: set[str], want: list[str]) -> tuple[list[str], list[str], float]:
|
||||
matched = [s for s in want if s in have]
|
||||
missing = [s for s in want if s not in have]
|
||||
cov = len(matched) / len(want) if want else 1.0
|
||||
return matched, missing, cov
|
||||
|
||||
|
||||
def topological_stages(missing: list[str], lexicon: dict) -> list[dict]:
|
||||
"""把缺失技能按前置关系拓扑排序分阶段(Kahn 分层)。
|
||||
|
||||
前置关系取自技能词表 prereqs;前置技能若已掌握或不在缺失集中则视为已满足。
|
||||
每个阶段附带结构化验收标准(产出物 / 量化验收项 / 自测问答),按技能类别生成。
|
||||
"""
|
||||
prereq = {s["name"]: [p for p in s.get("prereqs", []) if p in missing]
|
||||
for s in lexicon["skills"] if s["name"] in missing}
|
||||
cat_of = {s["name"]: s.get("category", "") for s in lexicon["skills"]}
|
||||
remaining = set(missing)
|
||||
stages: list[dict] = []
|
||||
stage_no = 1
|
||||
while remaining:
|
||||
ready = sorted(s for s in remaining if not [p for p in prereq.get(s, []) if p in remaining])
|
||||
if not ready: # 有环兜底:按字典序放出,保证终止
|
||||
ready = sorted(remaining)
|
||||
stages.append({"stage": stage_no, "skills": ready,
|
||||
"goal": f"第 {stage_no} 阶段:掌握 {'、'.join(ready)}",
|
||||
"acceptance": build_acceptance(ready, cat_of)})
|
||||
remaining -= set(ready)
|
||||
stage_no += 1
|
||||
return stages
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 验收标准模板
|
||||
# 按技能类别给出差异化的验收过程:产出物、量化验收项、自测问答。
|
||||
# {skills} 占位为本阶段技能名(顿号连接),{s1}/{s2} 为前两个技能。
|
||||
_ACCEPTANCE: dict[str, dict] = {
|
||||
"编程语言": {
|
||||
"deliverable": "用 {skills} 独立完成一个 300 行以上、可运行的小工具或 CLI 程序,源码提交仓库并附 README",
|
||||
"criteria": ["覆盖核心语法与常用标准库,代码可一次性运行无报错",
|
||||
"编写不少于 5 个单元测试用例并全部通过",
|
||||
"README 说明设计取舍与至少 2 个踩坑记录",
|
||||
"代码符合该语言主流风格规范(通过对应 linter 检查)"],
|
||||
"self_check": ["{s1} 与一门相近语言在类型与内存模型上的核心差异是什么?",
|
||||
"{s1} 最常见的 3 个性能陷阱是什么,如何规避?",
|
||||
"如果把这个工具的数据规模放大 100 倍,瓶颈会先出现在哪里?"],
|
||||
},
|
||||
"后端框架": {
|
||||
"deliverable": "基于 {skills} 实现一个不少于 3 个接口的 REST 服务,含鉴权与参数校验",
|
||||
"criteria": ["分层清晰(路由/服务/数据访问分离),无不合理耦合",
|
||||
"关键接口有自动化测试,覆盖率不低于 60%",
|
||||
"完成一次简单压测并记录 QPS 与耗时分布",
|
||||
"能手绘并讲解一次请求的完整生命周期"],
|
||||
"self_check": ["{s1} 的依赖注入/中间件机制是如何工作的?",
|
||||
"生产环境下 {s1} 服务如何做优雅停机与连接管理?",
|
||||
"哪些场景下你会放弃 {s1} 而选择更轻的框架?"],
|
||||
},
|
||||
"前端": {
|
||||
"deliverable": "用 {skills} 完成一个可运行的单页应用(不少于 2 个页面),公开访问或录屏演示",
|
||||
"criteria": ["组件拆分合理、状态管理方案有明确选型理由",
|
||||
"主流浏览器无 console 报错,页面响应式适配两种宽度",
|
||||
"完成一次构建优化(分包/懒加载),记录优化前后体积",
|
||||
"核心交互有至少 3 个组件级测试"],
|
||||
"self_check": ["{s1} 的渲染/更新机制是怎样的,什么操作会触发不必要的重渲染?",
|
||||
"状态提升与全局状态各自适合什么场景?",
|
||||
"首屏白屏时间可以从哪几个环节优化?"],
|
||||
},
|
||||
"数据库": {
|
||||
"deliverable": "围绕 {skills} 设计并实现一个业务库表方案与查询集,附索引设计说明",
|
||||
"criteria": ["给出 ER 图与范式/反范式取舍的书面说明",
|
||||
"用 EXPLAIN 分析并优化至少 2 条慢查询,记录前后对比",
|
||||
"通过实验验证事务隔离级别的差异(附复现 SQL)",
|
||||
"完成一次备份与恢复演练"],
|
||||
"self_check": ["什么情况下索引会失效?各举一个例子",
|
||||
"{s1} 的 redo/undo 或持久化机制保证了什么?",
|
||||
"千万级数据量下,分页查询如何优化?"],
|
||||
},
|
||||
"中间件": {
|
||||
"deliverable": "本地搭建 {skills} 环境并跑通生产/消费(或读写)完整 demo",
|
||||
"criteria": ["给出关键配置项(持久化/高可用/过期策略)的逐项说明",
|
||||
"演练消息丢失、重复消费或缓存穿透场景并给出防护方案",
|
||||
"接入监控并解读 3 个核心指标",
|
||||
"整理一份与同类中间件的选型对比表"],
|
||||
"self_check": ["{s1} 在什么条件下会丢数据?如何避免?",
|
||||
"集群模式下数据是如何分布与迁移的?",
|
||||
"什么业务场景下不该使用 {s1}?"],
|
||||
},
|
||||
"大数据": {
|
||||
"deliverable": "用 {skills} 跑通一个批处理或流处理作业,输入输出可复现",
|
||||
"criteria": ["说明分区与并行度策略及其依据",
|
||||
"制造并解决一次数据倾斜,记录处理前后耗时",
|
||||
"给出作业的容错与重跑方案",
|
||||
"输出结果有数据质量校验(行数/空值/分布)"],
|
||||
"self_check": ["{s1} 的 shuffle 过程发生了什么,为什么昂贵?",
|
||||
"批处理与流处理的语义差异体现在哪里?",
|
||||
"如何判断该作业该垂直扩容还是水平扩容?"],
|
||||
},
|
||||
"AI算法": {
|
||||
"deliverable": "基于 {skills} 复现一个 baseline 模型或算法 demo,附实验记录",
|
||||
"criteria": ["数据集划分规范(训练/验证/测试),杜绝数据泄漏",
|
||||
"输出完整指标报告(P/R/F1 或 Loss/Acc 曲线)",
|
||||
"完成至少 1 组消融或调参对比并记录结论",
|
||||
"能讲清模型的适用边界与失败案例"],
|
||||
"self_check": ["如何判断模型过拟合?你用了哪些手段缓解?",
|
||||
"指标不变差的前提下,推理成本可以怎么降?",
|
||||
"{s1} 与传统方法相比,优势和代价各是什么?"],
|
||||
},
|
||||
"云原生": {
|
||||
"deliverable": "将一个应用容器化并部署到 {skills} 环境,完成一次滚动更新与回滚",
|
||||
"criteria": ["Dockerfile 使用多阶段构建,镜像体积有优化记录",
|
||||
"编排配置包含资源限额、健康探针与副本策略",
|
||||
"演练滚动更新与回滚全过程并截图/录屏",
|
||||
"日志与监控接入,能定位一次人为注入的故障"],
|
||||
"self_check": ["三种探针的区别是什么,配错会导致什么后果?",
|
||||
"有状态服务上 {s1} 要注意什么?",
|
||||
"镜像分层缓存如何影响 CI 构建速度?"],
|
||||
},
|
||||
"运维": {
|
||||
"deliverable": "用 {skills} 实现部署/巡检/告警中的至少一个自动化工具并投入使用演示",
|
||||
"criteria": ["工具覆盖部署、监控、告警三个环节中的至少两个",
|
||||
"完成一次故障注入演练(如杀进程/打满磁盘)并记录处置时长",
|
||||
"编写运行手册(部署步骤、回滚步骤、常见告警处置)",
|
||||
"脚本具备幂等性,重复执行不产生副作用"],
|
||||
"self_check": ["负载飙升时你的排查顺序是什么?",
|
||||
"监控覆盖率和告警疲劳之间怎么平衡?",
|
||||
"{s1} 自动化过程中最需要防的人为风险是什么?"],
|
||||
},
|
||||
"测试": {
|
||||
"deliverable": "为一个真实模块编写测试计划与自动化用例集,并在 CI 中跑通",
|
||||
"criteria": ["用例设计覆盖等价类、边界值与异常路径,附设计说明",
|
||||
"自动化用例不少于 10 条,CI 触发全绿",
|
||||
"提交至少 2 份规范缺陷报告(复现步骤/期望/实际/环境)",
|
||||
"输出覆盖率报告并说明未覆盖部分的风险"],
|
||||
"self_check": ["如何为一个你从未见过的系统设计测试策略?",
|
||||
"自动化测试的投入产出比怎么评估?",
|
||||
"线上问题复盘时,测试环节应该如何改进?"],
|
||||
},
|
||||
"数据分析": {
|
||||
"deliverable": "围绕 {skills} 完成一份端到端数据分析报告(数据获取→清洗→分析→结论)",
|
||||
"criteria": ["清洗与处理过程代码化、可一键复现",
|
||||
"给出不少于 3 个有业务含义的洞察,每个都有数据支撑",
|
||||
"图表选型恰当,坐标轴与口径标注完整",
|
||||
"明确写出结论的适用范围与可能的偏差来源"],
|
||||
"self_check": ["什么是辛普森悖论?如何在分析中规避?",
|
||||
"相关关系和因果关系的差异对结论有什么影响?",
|
||||
"如果数据缺失 30%,你的处理策略是什么?"],
|
||||
},
|
||||
"产品": {
|
||||
"deliverable": "围绕 {skills} 输出一份完整 PRD 或实验方案(含原型或流程图)",
|
||||
"criteria": ["明确定义北极星指标与至少 2 个护栏指标",
|
||||
"实验类方案需给出样本量估算、周期与显著性判定规则",
|
||||
"包含灰度发布与回滚预案",
|
||||
"预设结果判定规则:什么数据下继续/迭代/放弃"],
|
||||
"self_check": ["A/A 测试的目的是什么?不做会有什么风险?",
|
||||
"p 值和置信区间分别说明什么,常见误用有哪些?",
|
||||
"指标提升但用户体验下降时,决策依据是什么?"],
|
||||
},
|
||||
"嵌入式": {
|
||||
"deliverable": "基于 {skills} 完成一个可烧录运行的固件 demo",
|
||||
"criteria": ["完成交叉编译与烧录,功能演示正常",
|
||||
"给出关键中断/时序的分析记录",
|
||||
"测量并记录功耗或资源占用数据",
|
||||
"代码有看门狗与异常恢复处理"],
|
||||
"self_check": ["裸机与 RTOS 的核心差异是什么,何时必须上 RTOS?",
|
||||
"如何定位一次偶发的硬件通信失败?",
|
||||
"Flash/RAM 受限时的优化手段有哪些?"],
|
||||
},
|
||||
"物联网": {
|
||||
"deliverable": "用 {skills} 搭建一条设备数据采集→上报→可视化的端到端链路",
|
||||
"criteria": ["说明通信协议选型理由(如 MQTT/CoAP/HTTP)",
|
||||
"实现断线重连与本地缓存补传",
|
||||
"完成弱网/断电场景下的鲁棒性演练",
|
||||
"数据可视化看板可实时刷新"],
|
||||
"self_check": ["弱网环境下消息的可靠性与时延如何权衡?",
|
||||
"设备身份认证与防伪造怎么做?",
|
||||
"海量设备接入时,架构的瓶颈会先出现在哪?"],
|
||||
},
|
||||
}
|
||||
|
||||
_DEFAULT_ACCEPTANCE = {
|
||||
"deliverable": "独立完成一个综合运用 {skills} 的迷你项目,源码与说明文档齐全",
|
||||
"criteria": ["项目可运行、核心流程演示无阻塞",
|
||||
"能完整讲解 {s1} 的核心原理与常见坑",
|
||||
"整理不少于 5 条学习笔记或踩坑记录",
|
||||
"向他人做一次 10 分钟分享并通过提问环节"],
|
||||
"self_check": ["{s1} 解决了什么问题?没有它时人们怎么做?",
|
||||
"学习 {s1} 过程中最反直觉的一点是什么?",
|
||||
"如何把本阶段技能组合应用到目标岗位的真实场景中?"],
|
||||
}
|
||||
|
||||
|
||||
def build_acceptance(skills: list[str], cat_of: dict[str, str]) -> dict:
|
||||
"""按阶段内技能的主导类别选择模板,生成结构化验收标准。"""
|
||||
cats = [cat_of.get(s, "") for s in skills if cat_of.get(s)]
|
||||
if cats:
|
||||
# 主导类别(并列时取阶段内先出现的技能类别)
|
||||
cat = max(cats, key=lambda c: (cats.count(c), -cats.index(c)))
|
||||
else:
|
||||
cat = ""
|
||||
tpl = _ACCEPTANCE.get(cat, _DEFAULT_ACCEPTANCE)
|
||||
s1 = skills[0]
|
||||
s2 = skills[1] if len(skills) > 1 else skills[0]
|
||||
joined = "、".join(skills)
|
||||
|
||||
def fmt(text: str) -> str:
|
||||
return text.format(skills=joined, s1=s1, s2=s2)
|
||||
|
||||
return {
|
||||
"category": cat or "通用",
|
||||
"deliverable": fmt(tpl["deliverable"]),
|
||||
"criteria": [fmt(c) for c in tpl["criteria"]],
|
||||
"self_check": [fmt(q) for q in tpl["self_check"]],
|
||||
}
|
||||
|
||||
|
||||
class MatchEngine:
|
||||
"""人岗匹配引擎。"""
|
||||
|
||||
def __init__(self, lexicon: dict | None = None, weights: dict | None = None,
|
||||
threshold: float = 60.0):
|
||||
self.lexicon = lexicon or load_lexicon()
|
||||
self.weights = {**DEFAULT_WEIGHTS, **(weights or {})}
|
||||
self.threshold = threshold
|
||||
self.category_of = {s["name"]: s.get("category", "") for s in self.lexicon["skills"]}
|
||||
|
||||
def match(self, profile: ResumeProfile, job: JobDefinition) -> MatchResult:
|
||||
have = set(profile.skills)
|
||||
m_req, miss_req, cov_req = _coverage(have, job.required_skills)
|
||||
m_pref, miss_pref, cov_pref = _coverage(have, job.preferred_skills)
|
||||
exp_score = min(1.0, profile.years / max(1, job.expected_years))
|
||||
scen_matched = [s for s in job.scenarios if s in set(profile.scenarios)]
|
||||
cov_scen = len(scen_matched) / len(job.scenarios) if job.scenarios else 1.0
|
||||
|
||||
dims = {
|
||||
"required": round(100 * cov_req, 2),
|
||||
"preferred": round(100 * cov_pref, 2),
|
||||
"experience": round(100 * exp_score, 2),
|
||||
"scenario": round(100 * cov_scen, 2),
|
||||
}
|
||||
total = round(100 * (
|
||||
self.weights["required"] * cov_req
|
||||
+ self.weights["preferred"] * cov_pref
|
||||
+ self.weights["experience"] * exp_score
|
||||
+ self.weights["scenario"] * cov_scen
|
||||
), 2)
|
||||
|
||||
# 可迁移技能:与缺失必备技能同类别、且候选人已掌握的技能
|
||||
miss_cats = {self.category_of.get(s, "") for s in miss_req} - {""}
|
||||
transferable = sorted(s for s in have
|
||||
if self.category_of.get(s, "") in miss_cats
|
||||
and s not in job.required_skills)
|
||||
|
||||
path = topological_stages(miss_req + miss_pref, self.lexicon)
|
||||
return MatchResult(
|
||||
job_id=job.job_id,
|
||||
job_title=job.title,
|
||||
total_score=total,
|
||||
dimension_scores=dims,
|
||||
matched_required=m_req,
|
||||
missing_required=miss_req,
|
||||
matched_preferred=m_pref,
|
||||
missing_preferred=miss_pref,
|
||||
transferable_skills=transferable,
|
||||
learning_path=path,
|
||||
is_match=total >= self.threshold,
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""简历解析:.txt 直接读,.docx 用 python-docx,.pdf 用 pypdf。
|
||||
|
||||
解析后做四件事:脱敏(姓名/手机/邮箱/住址打码)、技能抽取并归一化到词表、
|
||||
工作年限与场景识别、结构化信息抽取(性别/学历/住址/分节/证件照),
|
||||
供规范简历模板展示与导出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.extract.base import SkillMatcher, load_lexicon
|
||||
from app.models import ResumeProfile
|
||||
|
||||
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})")
|
||||
_YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验")
|
||||
_GENDER_RE = re.compile(r"性别[::]?\s*(男|女)")
|
||||
_EDU_RE = re.compile(r"(博士后|博士|硕士|研究生|MBA|本科|大专|专科)")
|
||||
_ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})")
|
||||
|
||||
# 常见简历分节标题(整行匹配,可带冒号)
|
||||
_SECTION_TITLES = {
|
||||
"个人信息", "基本信息", "求职意向", "教育经历", "教育背景",
|
||||
"工作经历", "工作经验", "实习经历", "项目经历", "项目经验",
|
||||
"技能清单", "专业技能", "核心技能", "技能特长", "技能",
|
||||
"自我评价", "个人评价", "荣誉证书", "荣誉奖项", "证书",
|
||||
}
|
||||
|
||||
|
||||
def mask_text(text: str) -> str:
|
||||
"""脱敏:手机、邮箱、姓名、住址打码。"""
|
||||
text = _PHONE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-2:], text)
|
||||
text = _EMAIL_RE.sub(lambda m: m.group(0)[0] + "***@" + m.group(0).split("@")[1], text)
|
||||
text = _NAME_RE.sub(lambda m: "姓名:" + m.group(1)[0] + "*" * (len(m.group(1)) - 1), text)
|
||||
text = _ADDR_RE.sub(lambda m: m.group(0)[:2] + m.group(1)[:6] + "***"
|
||||
if len(m.group(1)) > 6 else m.group(0), text)
|
||||
return text
|
||||
|
||||
|
||||
def split_sections(text: str) -> list[dict]:
|
||||
"""按常见标题把简历正文分节,返回 [{title, text}];无标题时整体归入"简历全文"。"""
|
||||
sections: list[dict] = []
|
||||
cur_title, cur_lines = "基本信息", []
|
||||
for ln in text.splitlines():
|
||||
key = ln.strip().rstrip("::").strip()
|
||||
if key in _SECTION_TITLES:
|
||||
if "\n".join(cur_lines).strip():
|
||||
sections.append({"title": cur_title, "text": "\n".join(cur_lines).strip()})
|
||||
cur_title, cur_lines = key, []
|
||||
else:
|
||||
cur_lines.append(ln)
|
||||
if "\n".join(cur_lines).strip():
|
||||
sections.append({"title": cur_title, "text": "\n".join(cur_lines).strip()})
|
||||
if not sections and text.strip():
|
||||
sections = [{"title": "简历全文", "text": text.strip()}]
|
||||
return sections
|
||||
|
||||
|
||||
def extract_photo_b64(path: str | Path) -> str:
|
||||
"""从 docx/pdf 中提取第一张图片作为证件照,返回 data URI;无图片返回空串。"""
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
try:
|
||||
if suffix == ".docx":
|
||||
import zipfile
|
||||
with zipfile.ZipFile(str(path)) as z:
|
||||
names = sorted(n for n in z.namelist()
|
||||
if n.startswith("word/media/")
|
||||
and n.lower().endswith((".png", ".jpg", ".jpeg")))
|
||||
if not names:
|
||||
return ""
|
||||
data = z.read(names[0])
|
||||
mime = "image/png" if names[0].lower().endswith(".png") else "image/jpeg"
|
||||
return f"data:{mime};base64,{base64.b64encode(data).decode()}"
|
||||
if suffix == ".pdf":
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader(str(path))
|
||||
for page in reader.pages[:2]:
|
||||
for img in getattr(page, "images", []):
|
||||
mime = "image/png" if img.name.lower().endswith(".png") else "image/jpeg"
|
||||
return f"data:{mime};base64,{base64.b64encode(img.data).decode()}"
|
||||
except Exception as exc: # 照片是增强信息,提取失败不阻断解析
|
||||
logger.warning("简历照片提取失败(已跳过): %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def read_resume_file(path: str | Path) -> str:
|
||||
"""按扩展名读取简历文件文本。"""
|
||||
path = Path(path)
|
||||
suffix = path.suffix.lower()
|
||||
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)
|
||||
if suffix == ".pdf":
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"解析 .pdf 需要可选依赖 pypdf,请先执行:pip install pypdf"
|
||||
) from exc
|
||||
reader = PdfReader(str(path))
|
||||
return "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||
raise ValueError(f"不支持的简历格式:{suffix}(支持 .txt/.docx/.pdf)")
|
||||
|
||||
|
||||
def _mask_addr(addr: str) -> str:
|
||||
"""住址脱敏:保留前 6 字(大致到区/县),其余打码。"""
|
||||
return addr[:6] + "***" if len(addr) > 6 else addr
|
||||
|
||||
|
||||
def parse_resume(text: str, matcher: SkillMatcher | None = None,
|
||||
photo_b64: str = "") -> ResumeProfile:
|
||||
"""从简历文本解析脱敏画像(含结构化基本信息与分节)。"""
|
||||
matcher = matcher or SkillMatcher(load_lexicon())
|
||||
name_m = _NAME_RE.search(text)
|
||||
phone_m = _PHONE_RE.search(text)
|
||||
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(
|
||||
name=(name_m.group(1)[0] + "*" * (len(name_m.group(1)) - 1)) if name_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 "",
|
||||
years=float(years_m.group(1)) if years_m else 0.0,
|
||||
skills=matcher.find_names(text),
|
||||
scenarios=matcher.find_scenarios(text),
|
||||
raw_masked_text=masked,
|
||||
gender=gender_m.group(1) if gender_m else "",
|
||||
education=edu_m.group(1) if edu_m else "",
|
||||
address=_mask_addr(addr_m.group(1).strip()) if addr_m else "",
|
||||
photo_b64=photo_b64,
|
||||
sections=split_sections(masked),
|
||||
)
|
||||
|
||||
|
||||
def parse_resume_file(path: str | Path, matcher: SkillMatcher | None = None) -> ResumeProfile:
|
||||
"""读取文件并解析(含证件照提取)。"""
|
||||
return parse_resume(read_resume_file(path), matcher,
|
||||
photo_b64=extract_photo_b64(path))
|
||||
@@ -0,0 +1,133 @@
|
||||
"""规范简历 DOCX 导出:照片 + 基本信息表 + 分节正文(关键词高亮)+ 可选匹配诊断。
|
||||
|
||||
完全离线,仅依赖 python-docx。返回 bytes,由 API 层以文件流下发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import re
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
|
||||
from app.models import ResumeProfile
|
||||
|
||||
_GREEN = RGBColor(0x00, 0xB4, 0x2A) # 命中必备
|
||||
_BLUE = RGBColor(0x2F, 0x54, 0xEB) # 命中加分
|
||||
_GRAY = RGBColor(0x4E, 0x59, 0x69) # 其他技能
|
||||
_DARK = RGBColor(0x1D, 0x21, 0x29)
|
||||
|
||||
|
||||
def _set_cn_font(doc: Document) -> None:
|
||||
st = doc.styles["Normal"]
|
||||
st.font.name = "Times New Roman"
|
||||
st.font.size = Pt(10.5)
|
||||
st.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
|
||||
|
||||
def _highlight_runs(paragraph, text: str, cls_of: dict[str, RGBColor]) -> None:
|
||||
"""按技能清单把 text 切段写入段落,命中技能加粗上色。"""
|
||||
if not cls_of:
|
||||
paragraph.add_run(text)
|
||||
return
|
||||
names = sorted(cls_of, key=len, reverse=True)
|
||||
pat = re.compile("|".join(re.escape(n) for n in names), re.IGNORECASE)
|
||||
pos = 0
|
||||
for m in pat.finditer(text):
|
||||
if m.start() > pos:
|
||||
paragraph.add_run(text[pos:m.start()])
|
||||
hit = m.group(0)
|
||||
run = paragraph.add_run(hit)
|
||||
run.bold = True
|
||||
run.font.color.rgb = cls_of.get(hit.lower(), _DARK)
|
||||
pos = m.end()
|
||||
if pos < len(text):
|
||||
paragraph.add_run(text[pos:])
|
||||
|
||||
|
||||
def build_resume_docx(profile: ResumeProfile, match: dict | None = None) -> bytes:
|
||||
"""把脱敏简历画像渲染为规范 DOCX,返回文件 bytes。"""
|
||||
doc = Document()
|
||||
_set_cn_font(doc)
|
||||
|
||||
title = doc.add_paragraph()
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
trun = title.add_run("个 人 简 历")
|
||||
trun.bold = True
|
||||
trun.font.size = Pt(20)
|
||||
sub = doc.add_paragraph()
|
||||
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
srun = sub.add_run("(CareerNexus 职联 · 结构化解析导出,已自动脱敏)")
|
||||
srun.font.size = Pt(9)
|
||||
srun.font.color.rgb = _GRAY
|
||||
|
||||
# ---- 头部:左照片 + 右基本信息表 ----
|
||||
head = doc.add_table(rows=1, cols=2)
|
||||
head.style = "Table Grid"
|
||||
head.columns[0].width = Cm(3.4)
|
||||
head.columns[1].width = Cm(13.4)
|
||||
photo_cell, info_cell = head.cell(0, 0), head.cell(0, 1)
|
||||
if profile.photo_b64:
|
||||
try:
|
||||
raw = base64.b64decode(profile.photo_b64.split(",", 1)[1])
|
||||
photo_cell.paragraphs[0].add_run().add_picture(io.BytesIO(raw), width=Cm(3.0))
|
||||
except Exception:
|
||||
photo_cell.paragraphs[0].add_run("(照片解析失败)")
|
||||
else:
|
||||
photo_cell.paragraphs[0].add_run("(未提供照片)")
|
||||
|
||||
info_items = [
|
||||
("姓名", profile.name or "—"), ("性别", profile.gender or "—"),
|
||||
("学历", profile.education or "—"), ("工作年限", f"{profile.years:g} 年" if profile.years else "—"),
|
||||
("手机", profile.phone or "—"), ("邮箱", profile.email or "—"),
|
||||
("家庭住址", profile.address or "—"),
|
||||
]
|
||||
info_table = info_cell.add_table(rows=(len(info_items) + 1) // 2, cols=2)
|
||||
for i, (k, v) in enumerate(info_items):
|
||||
cell = info_table.cell(i // 2, i % 2)
|
||||
p = cell.paragraphs[0]
|
||||
kr = p.add_run(f"{k}:")
|
||||
kr.bold = True
|
||||
p.add_run(v)
|
||||
|
||||
# 技能高亮类别(小写键)
|
||||
cls_of: dict[str, RGBColor] = {}
|
||||
if match:
|
||||
for s in match.get("matched_required", []):
|
||||
cls_of[s.lower()] = _GREEN
|
||||
for s in match.get("matched_preferred", []):
|
||||
cls_of[s.lower()] = _BLUE
|
||||
for s in profile.skills:
|
||||
cls_of.setdefault(s.lower(), _GRAY)
|
||||
|
||||
# ---- 分节正文(关键词标注) ----
|
||||
for sec in profile.sections or [{"title": "简历全文", "text": profile.raw_masked_text}]:
|
||||
h = doc.add_paragraph()
|
||||
hrun = h.add_run(f"▍{sec['title']}")
|
||||
hrun.bold = True
|
||||
hrun.font.size = Pt(13)
|
||||
hrun.font.color.rgb = _BLUE
|
||||
_highlight_runs(doc.add_paragraph(), sec["text"], cls_of)
|
||||
|
||||
# ---- 匹配诊断摘要(可选) ----
|
||||
if match and match.get("job_title"):
|
||||
h = doc.add_paragraph()
|
||||
hrun = h.add_run("▍人岗匹配诊断(CareerNexus)")
|
||||
hrun.bold = True
|
||||
hrun.font.size = Pt(13)
|
||||
hrun.font.color.rgb = _BLUE
|
||||
for line in [
|
||||
f"目标岗位:{match['job_title']} 综合匹配分:{match.get('total_score', 0)}",
|
||||
f"命中必备技能:{'、'.join(match.get('matched_required', [])) or '无'}",
|
||||
f"命中加分技能:{'、'.join(match.get('matched_preferred', [])) or '无'}",
|
||||
f"缺失技能:{'、'.join((match.get('missing_required', [])
|
||||
+ match.get('missing_preferred', []))) or '无'}",
|
||||
]:
|
||||
doc.add_paragraph(line)
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""语义向量通道(可选增强):本地 BGE-M3 权重 或 联网 Embedding API 双模式。
|
||||
|
||||
- provider=local:sentence-transformers 加载本地权重(如 models/bge-m3);
|
||||
- provider=api:OpenAI 兼容 /embeddings 接口(默认阿里 DashScope 兼容模式,
|
||||
模型 text-embedding-v4,填 API Key 即可,无需下载权重);
|
||||
- 任一模式就绪即启用语义召回;均不可用时回退词表归一化,行为与无此模块一致;
|
||||
- 依赖与权重均为可选安装,不进入 requirements("零重依赖、离线优先"约束)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# BGE-M3 目录的就绪判定:配置文件 + 任一权重文件 + 分词器
|
||||
_REQUIRED_CONFIG = "config.json"
|
||||
_WEIGHT_FILES = ("model.safetensors", "pytorch_model.bin")
|
||||
_TOKENIZER_FILES = ("tokenizer.json", "vocab.txt")
|
||||
|
||||
DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
DEFAULT_API_MODEL = "text-embedding-v4"
|
||||
|
||||
_model = None # 懒加载的本地 SentenceTransformer 实例
|
||||
_model_path = ""
|
||||
_skill_emb = None # 词表技能向量缓存
|
||||
_skill_names: list[str] = []
|
||||
_emb_cache_key = "" # 缓存对应的通道标识(provider+路径/模型)
|
||||
|
||||
|
||||
def deps_installed() -> bool:
|
||||
"""sentence-transformers 是否已安装。"""
|
||||
return importlib.util.find_spec("sentence_transformers") is not None
|
||||
|
||||
|
||||
def resolve_weights_path(path: str | Path) -> Path:
|
||||
"""权重路径解析:相对路径相对工程根目录解析,绝对路径原样返回(兼容既有配置)。"""
|
||||
from app.config import ROOT
|
||||
p = Path(str(path or ""))
|
||||
return p if p.is_absolute() else ROOT / p
|
||||
|
||||
|
||||
def weights_present(path: str | Path) -> bool:
|
||||
"""权重目录是否包含 BGE-M3 所需文件。"""
|
||||
p = Path(str(path or ""))
|
||||
if not p.is_dir() or not (p / _REQUIRED_CONFIG).exists():
|
||||
return False
|
||||
if not any((p / f).exists() for f in _WEIGHT_FILES):
|
||||
return False
|
||||
return any((p / f).exists() for f in _TOKENIZER_FILES)
|
||||
|
||||
|
||||
def status(cfg: dict) -> dict:
|
||||
"""语义通道状态:供 /api/health 与 /api/config 展示。"""
|
||||
provider = str(cfg.get("embed_provider") or "local").strip() or "local"
|
||||
path = str(cfg.get("embed_model_path") or "").strip()
|
||||
api_key = str(cfg.get("embed_api_key") or "").strip()
|
||||
present = weights_present(resolve_weights_path(path)) if path else False
|
||||
deps = deps_installed()
|
||||
local_ready = bool(path and present and deps)
|
||||
api_ready = bool(api_key)
|
||||
ready = api_ready if provider == "api" else local_ready
|
||||
return {
|
||||
"provider": provider,
|
||||
"configured": bool(path) if provider == "local" else api_ready,
|
||||
"path": path,
|
||||
"weights_present": present,
|
||||
"deps_installed": deps,
|
||||
"local_ready": local_ready,
|
||||
"api_key_masked": ("••••" + api_key[-4:]) if api_key else "",
|
||||
"api_base_url": str(cfg.get("embed_api_base_url") or DEFAULT_API_BASE),
|
||||
"api_model": str(cfg.get("embed_api_model") or DEFAULT_API_MODEL),
|
||||
"ready": bool(ready),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 本地模式
|
||||
def _load_local(path: str):
|
||||
"""懒加载本地模型;不可用时返回 None。"""
|
||||
global _model, _model_path
|
||||
if _model is not None and _model_path == path:
|
||||
return _model
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer # 可选依赖
|
||||
_model = SentenceTransformer(path)
|
||||
_model_path = path
|
||||
logger.info("语义向量模型已加载(本地): %s", path)
|
||||
except Exception as exc: # noqa: BLE001 - 模型加载失败必须可回退
|
||||
logger.warning("本地语义模型加载失败,回退词表归一化: %s", exc)
|
||||
_model = None
|
||||
return None
|
||||
return _model
|
||||
|
||||
|
||||
def _embed_local(texts: list[str], path: str):
|
||||
model = _load_local(path)
|
||||
if model is None:
|
||||
return None
|
||||
return model.encode(texts, normalize_embeddings=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 联网模式
|
||||
def _embed_api(texts: list[str], cfg: dict):
|
||||
"""调用 OpenAI 兼容 embeddings 接口(DashScope 等),返回向量列表。"""
|
||||
st = status(cfg)
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{st['api_base_url'].rstrip('/')}/embeddings",
|
||||
headers={"Authorization": f"Bearer {cfg.get('embed_api_key')}"},
|
||||
json={"model": st["api_model"], "input": texts},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()["data"]
|
||||
data.sort(key=lambda d: d["index"])
|
||||
import math
|
||||
vecs = []
|
||||
for d in data:
|
||||
v = d["embedding"]
|
||||
norm = math.sqrt(sum(x * x for x in v)) or 1.0
|
||||
vecs.append([x / norm for x in v])
|
||||
return vecs
|
||||
except Exception as exc: # noqa: BLE001 - 网络/鉴权失败必须可回退
|
||||
logger.warning("Embedding API 调用失败,回退词表归一化: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 统一入口
|
||||
def _embed(texts: list[str], cfg: dict):
|
||||
"""按 provider 取向量(已归一化);失败返回 None。"""
|
||||
provider = str(cfg.get("embed_provider") or "local").strip() or "local"
|
||||
if provider == "api":
|
||||
return _embed_api(texts, cfg)
|
||||
return _embed_local(texts, str(resolve_weights_path(
|
||||
str(cfg.get("embed_model_path") or "").strip())))
|
||||
|
||||
|
||||
def most_similar(name: str, cfg: dict, lexicon: dict,
|
||||
threshold: float = 0.62) -> tuple[str, float] | None:
|
||||
"""返回词表中与 name 语义最近的技能 (标准名, 相似度);未过阈值或不可用返回 None。"""
|
||||
global _skill_emb, _skill_names, _emb_cache_key
|
||||
st = status(cfg)
|
||||
if not st["ready"]:
|
||||
return None
|
||||
cache_key = f"{st['provider']}|{st['path'] or st['api_model']}"
|
||||
if _skill_emb is None or cache_key != _emb_cache_key:
|
||||
_skill_names = [s["name"] for s in lexicon["skills"]]
|
||||
_skill_emb = _embed(_skill_names, cfg)
|
||||
if _skill_emb is None:
|
||||
return None
|
||||
_emb_cache_key = cache_key
|
||||
vecs = _embed([name], cfg)
|
||||
if vecs is None:
|
||||
return None
|
||||
vec = vecs[0]
|
||||
scores = [sum(a * b for a, b in zip(row, vec)) for row in _skill_emb]
|
||||
best_i = max(range(len(scores)), key=lambda i: scores[i])
|
||||
if scores[best_i] >= threshold:
|
||||
return _skill_names[best_i], round(scores[best_i], 4)
|
||||
return None
|
||||
Reference in New Issue
Block a user