CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)

- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径
- WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径
- 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API)
- 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
This commit is contained in:
RoyirinRosmontis
2026-08-03 13:15:35 +08:00
commit a10442d78c
78 changed files with 12860 additions and 0 deletions
View File
+192
View File
@@ -0,0 +1,192 @@
"""时序知识图谱(TemporalKG)。
- 节点:Job / Skill / Industry / Scenario;
- 关系:REQUIRES,属性含 importance(必备/加分)、weight、sources,
以及 intervals:[[valid_from, valid_to|null]] 生效时间区间列表;
- 演化事件只追加不覆盖:同一 (岗位, 技能) 的历史通过区间与事件流共同表达;
- snapshot(job_id, date):任意时间点(或季度窗口)切片查询;
- JSON 持久化与加载。
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime
from pathlib import Path
import networkx as nx
from app.models import ChangeEvent, GraphObservation, JobDefinition
logger = logging.getLogger(__name__)
def quarter_of(dt) -> str:
"""日期 -> 季度窗口字符串,如 2022Q1。窗口串按字典序即时间序。"""
return f"{dt.year}Q{(dt.month - 1) // 3 + 1}"
def to_window(d: str) -> str:
"""把 'YYYY-MM-DD' 或 'YYYYQn' 统一为季度窗口串。"""
if "Q" in d:
return d
parts = d.split("-")
return f"{parts[0]}Q{(int(parts[1]) - 1) // 3 + 1}"
def job_level(expected_years: int | None) -> str:
"""由期望年限推出岗位级别:≤2 初级 / 3-5 中级 / >5 高级。"""
y = expected_years if expected_years is not None else 3
return "初级" if y <= 2 else ("中级" if y <= 5 else "高级")
# 级别 -> 代表年限(人工编辑级别时同步回 expected_years,保持筛选一致)
LEVEL_TO_YEARS = {"初级": 2, "中级": 4, "高级": 6}
class TemporalKG:
"""基于 networkx DiGraph 的时序知识图谱。"""
def __init__(self):
self.g = nx.DiGraph()
self.jobs: dict[str, JobDefinition] = {}
self.events: list[ChangeEvent] = []
# ---------- 节点 ----------
def add_job(self, job: JobDefinition) -> None:
self.jobs[job.job_id] = job
self.g.add_node(job.job_id, type="Job", title=job.title,
category=job.category, status=job.status,
expected_years=job.expected_years,
level=job_level(job.expected_years),
source_count=job.source_count,
company_count=job.company_count)
def add_skill(self, name: str, category: str = "") -> None:
if name not in self.g:
self.g.add_node(name, type="Skill", category=category)
def add_scenario(self, name: str) -> None:
node = f"场景::{name}"
if node not in self.g:
self.g.add_node(node, type="Scenario", title=name)
def add_industry(self, name: str) -> None:
node = f"行业::{name}"
if node not in self.g:
self.g.add_node(node, type="Industry", title=name)
# ---------- 关系(追加不覆盖) ----------
def observe(self, obs: GraphObservation) -> None:
"""追加一次岗位-技能观察:新关系开区间,已有关系更新权重与来源。"""
self.add_skill(obs.skill)
if self.g.has_edge(obs.job_id, obs.skill):
attrs = self.g[obs.job_id][obs.skill]
# 最新权重按观察权重滑动更新;sources 累积不去重覆盖历史
attrs["weight"] = round(max(attrs["weight"], obs.weight), 4)
for sid in obs.source_ids:
if sid not in attrs["sources"]:
attrs["sources"].append(sid)
attrs["importance"] = obs.importance if obs.importance == "required" else attrs["importance"]
# 重新出现:若上一区间已关闭,则开新区间
last = attrs["intervals"][-1]
if last[1] is not None and last[1] <= obs.window:
attrs["intervals"].append([obs.window, None])
else:
self.g.add_edge(
obs.job_id, obs.skill, rel="REQUIRES",
importance=obs.importance, weight=round(obs.weight, 4),
intervals=[[obs.window, None]],
sources=list(obs.source_ids),
)
def close_interval(self, job_id: str, skill: str, end_window: str) -> None:
"""关闭 (岗位, 技能) 当前生效区间(技能退出)。"""
if self.g.has_edge(job_id, skill):
last = self.g[job_id][skill]["intervals"][-1]
if last[1] is None:
last[1] = end_window
# ---------- 事件 ----------
def add_event(self, ev: ChangeEvent) -> None:
self.events.append(ev)
def events_of(self, job_id: str) -> list[ChangeEvent]:
return [e for e in self.events if e.job_id == job_id]
# ---------- 切片查询 ----------
def snapshot(self, job_id: str, date_str: str) -> dict:
"""任意时间点切片:返回该时刻生效的技能关系。"""
window = to_window(date_str)
skills = []
if job_id in self.g:
for _, skill, attrs in self.g.out_edges(job_id, data=True):
if attrs.get("rel") != "REQUIRES":
continue
alive = any(s <= window and (e is None or window < e)
for s, e in attrs["intervals"])
if alive:
skills.append({
"skill": skill,
"importance": attrs["importance"],
"weight": attrs["weight"],
"sources": attrs["sources"],
})
job = self.jobs.get(job_id)
return {
"job_id": job_id,
"title": job.title if job else job_id,
"window": window,
"skills": skills,
}
def latest_snapshot(self, job_id: str) -> dict:
latest = "9999Q4"
return self.snapshot(job_id, latest)
# ---------- 统计 ----------
def stats(self) -> dict:
return {
"nodes": self.g.number_of_nodes(),
"edges": self.g.number_of_edges(),
"jobs": len(self.jobs),
"skills": sum(1 for _, d in self.g.nodes(data=True) if d.get("type") == "Skill"),
"events": len(self.events),
}
# ---------- 持久化 ----------
def save(self, path: str | Path) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"nodes": [{"id": n, **d} for n, d in self.g.nodes(data=True)],
"edges": [{"source": u, "target": v, **d} for u, v, d in self.g.edges(data=True)],
"jobs": {k: v.model_dump() for k, v in self.jobs.items()},
"events": [e.model_dump() for e in self.events],
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8")
logger.info("图谱已持久化到 %s(%d 节点 / %d 边)", path,
self.g.number_of_nodes(), self.g.number_of_edges())
@classmethod
def load(cls, path: str | Path) -> "TemporalKG":
kg = cls()
payload = json.loads(Path(path).read_text(encoding="utf-8"))
for n in payload["nodes"]:
nid = n.pop("id")
kg.g.add_node(nid, **n)
for e in payload["edges"]:
s, t = e.pop("source"), e.pop("target")
kg.g.add_edge(s, t, **e)
kg.jobs = {k: JobDefinition(**v) for k, v in payload["jobs"].items()}
kg.events = [ChangeEvent(**e) for e in payload["events"]]
return kg
def new_event_id() -> str:
return uuid.uuid4().hex[:12]
def now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")