- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
287 lines
16 KiB
Python
287 lines
16 KiB
Python
"""人岗匹配引擎:技能覆盖率召回 + 四维加权评分 + 差距分析 + 学习路径。
|
||
|
||
四维权重默认 必备 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,
|
||
)
|