Files
CareerNexus/app/graph/tkg.py
T
RoyirinRosmontis a10442d78c CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径
- WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径
- 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API)
- 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
2026-08-03 13:15:35 +08:00

193 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""时序知识图谱(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")