Files
CareerNexus/tests/test_persistence.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

374 lines
18 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.
"""P0 数据一致性测试:
- 审核入库岗位跨图谱重建持久化(真实区间/来源/场景行业边/演化事件);
- 节点编辑跨重建保留且与 /api/jobs 的 JobDefinition 一致;
- JD 手动导入(文件上传 / 文本粘贴 / 坏行清单 三路径);
- 访问令牌空串清除端到端;
- 语义权重相对路径(相对工程根解析)检测。
"""
from __future__ import annotations
import json
import time
from datetime import date
import pytest
from fastapi.testclient import TestClient
from app.api.routes import app
from app.config import load_config
from app.graph.tkg import TemporalKG
from app.models import JDRecord
@pytest.fixture(scope="module")
def client(graph_path):
with TestClient(app) as c: # 触发 startup 加载图谱
yield c
@pytest.fixture(autouse=True)
def _restore_kg():
"""本模块的 API 调用会改内存图谱;每个用例结束后从真实快照重载,避免串扰。"""
yield
import app.api.routes as routes
from app.config import resolve
gp = resolve(routes._cfg, "graph_path")
if gp.exists():
routes._state["kg"] = TemporalKG.load(gp)
def _jd(jid: str, title: str, pub: str, req: list[str], pref: list[str] | None = None) -> dict:
text = (f"岗位名称:{title}\n岗位职责:\n1、负责相关研发工作\n"
f"任职要求:\n1、熟悉 {'、'.join(req)}\n"
f"加分项:\n1、了解 {'、'.join(pref or ['团队协作'])}\n")
return {"jid": jid, "job_title": title, "company": f"测试公司{jid}", "city": "深圳",
"industry": "互联网/软件", "publish_date": pub, "text": text}
JAVA_RECORDS = [
_jd("j1", "Java开发工程师", "2022-01-15", ["Java", "MySQL"]),
_jd("j2", "Java开发工程师", "2022-04-15", ["Java", "Redis"]),
_jd("j3", "Java开发工程师", "2023-07-15", ["Java", "MySQL"]),
]
def _tmp_cfg(tmp_path, records) -> dict:
"""最小数据集 + 全部状态路径指向 tmp 的 pipeline 配置。"""
jd_dir = tmp_path / "jd_raw"
jd_dir.mkdir(exist_ok=True)
(jd_dir / "batch_2022.jsonl").write_text(
"\n".join(json.dumps(r, ensure_ascii=False) for r in records) + "\n", encoding="utf-8")
cfg = load_config()
cfg["jd_raw_dir"] = str(jd_dir)
cfg["graph_path"] = str(tmp_path / "graph.json")
cfg["state_db_path"] = str(tmp_path / "state.db")
cfg["audit_path"] = str(tmp_path / "audit.jsonl")
cfg["pending_path"] = str(tmp_path / "pending.json")
return cfg
def _wait_task(client, task_id: str) -> dict:
for _ in range(150):
t = client.get(f"/api/tasks/{task_id}").json()["data"]
if t["status"] in ("done", "failed"):
return t
time.sleep(0.2)
raise AssertionError("任务超时未完成")
# ---------- 1. 审核入库真实化 + 跨重建持久化 ----------
def test_accept_real_data_and_persists_across_rebuild(client, tmp_path, monkeypatch):
"""accept 用候选簇真实统计入库;pipeline 重建后岗位仍在(含场景/行业边与演化事件)。"""
import app.api.routes as routes
from app.discover.cluster import save_pending
from app.models import JobDefinition
from app.services.pipeline import run_pipeline
from app.storage import db as state_db
cfg = _tmp_cfg(tmp_path, JAVA_RECORDS)
db_path = tmp_path / "state.db"
monkeypatch.setitem(routes._cfg, "state_db_path", str(db_path))
monkeypatch.setitem(routes._cfg, "audit_path", str(tmp_path / "audit.jsonl"))
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph_api.json"))
cand = JobDefinition(
job_id="cand_大模型工程师", title="大模型工程师", category="新岗位候选",
industries=["人工智能"], required_skills=["Python", "PyTorch"],
preferred_skills=["Docker"], scenarios=["智能客服"], status="pending",
expected_years=3, source_count=12, company_count=5,
support_source_ids=["import_20260801.jsonl#1", "import_20260801.jsonl#2"],
support_windows=["2025Q4", "2025Q3"])
save_pending([cand], db_path)
r = client.post("/api/review", json={
"candidate_id": "cand_大模型工程师", "action": "accept", "operator": "张老师"})
assert r.status_code == 200
# 当前图谱:真实区间(首个支持窗口 2025Q3 起)与真实来源、场景/行业边、新增事件
g = client.get("/api/graph").json()["data"]
req_edges = [e for e in g["edges"]
if e["source"] == "job_大模型工程师" and e.get("rel") == "REQUIRES"]
assert len(req_edges) == 3
assert all(e["intervals"][0] == ["2025Q3", None] for e in req_edges)
assert all(set(e["sources"]) == {"import_20260801.jsonl#1", "import_20260801.jsonl#2"}
for e in req_edges)
others = [e for e in g["edges"] if e["source"] == "job_大模型工程师"]
assert any(e["rel"] == "APPLIES_TO" and e["target"] == "场景::智能客服" for e in others)
assert any(e["rel"] == "BELONGS_TO" and e["target"] == "行业::人工智能" for e in others)
evs = [e for e in g["events"] if e["job_id"] == "job_大模型工程师"]
assert len(evs) == 3
assert all(e["change_type"] == "新增" and e["window"] == "2025Q3" for e in evs)
# 覆写层已记录(跨重建持久化的依据)
assert state_db.get_override(db_path, "job_大模型工程师")["kind"] == "job_accept"
# 全量重建:数据集中根本没有该岗位(2023 后新岗位),仅靠覆写层重放强制纳入
stats = run_pipeline(cfg)
assert stats["stages"]["overrides"]["jobs"] == 1
# 已入库岗位不会重新出现在待审核分区
assert state_db.get_candidate(db_path, "cand_大模型工程师") is None
kg = TemporalKG.load(tmp_path / "graph.json")
assert "job_大模型工程师" in kg.jobs
job = kg.jobs["job_大模型工程师"]
assert job.status == "active" and job.title == "大模型工程师"
edge = kg.g["job_大模型工程师"]["Python"]
assert edge["intervals"][0] == ["2025Q3", None]
assert set(edge["sources"]) == {"import_20260801.jsonl#1", "import_20260801.jsonl#2"}
assert kg.g.has_edge("job_大模型工程师", "场景::智能客服")
assert kg.g.has_edge("job_大模型工程师", "行业::人工智能")
assert any(ev.change_type == "新增" and ev.window == "2025Q3"
for ev in kg.events_of("job_大模型工程师"))
def test_reject_clears_override(client, tmp_path, monkeypatch):
"""驳回:清理覆写层记录,重建后岗位不复活。"""
import app.api.routes as routes
from app.discover.cluster import save_pending
from app.models import JobDefinition
from app.storage import db as state_db
db_path = tmp_path / "state.db"
monkeypatch.setitem(routes._cfg, "state_db_path", str(db_path))
monkeypatch.setitem(routes._cfg, "audit_path", str(tmp_path / "audit.jsonl"))
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph_api.json"))
cand = JobDefinition(job_id="cand_短命岗位", title="短命岗位",
required_skills=["Python"], status="pending",
support_source_ids=["x.jsonl#1"], support_windows=["2025Q2"])
save_pending([cand], db_path)
state_db.save_override(db_path, "cand_短命岗位",
{"kind": "job_accept", "job": cand.model_dump()})
r = client.post("/api/review", json={
"candidate_id": "cand_短命岗位", "action": "reject",
"operator": "张老师", "reason": "证据不足"})
assert r.status_code == 200
assert state_db.get_override(db_path, "cand_短命岗位") is None
assert state_db.get_candidate(db_path, "cand_短命岗位") is None
def test_discover_candidate_carries_support_evidence():
"""新岗位发现:候选带支撑 source_id / 季度窗口 / 行业,供审核入库真实化。"""
from app.discover import cluster
from app.models import ExtractionResult
records, extractions = [], {}
for i in range(9):
r = JDRecord(jid=f"c{i}", job_title="提示词工程师", company=f"公司{i % 5}",
industry="人工智能", publish_date=date(2025, i % 6 + 1, 1),
text="负责大模型提示词设计与优化", source_id=f"t.jsonl#{i + 1}")
records.append(r)
extractions[r.jid] = ExtractionResult(
jid=r.jid, job_title="提示词工程师",
required_skills=["Python", "提示工程"], scenarios=["智能客服"])
cands = cluster.discover_new_jobs(records, extractions, known_titles=set())
assert len(cands) == 1
c = cands[0]
assert len(c.support_source_ids) == 9
assert min(c.support_windows) == "2025Q1"
assert c.industries == ["人工智能"]
# ---------- 2. 节点编辑同步 JobDefinition + 跨重建保留 ----------
def test_node_update_syncs_jobdef_and_persists(client, tmp_path, monkeypatch):
"""节点编辑同步 kg.jobs;重建后编辑保留且 /api/jobs 定义一致。"""
import app.api.routes as routes
from app.services.pipeline import run_pipeline
cfg = _tmp_cfg(tmp_path, JAVA_RECORDS)
db_path = tmp_path / "state.db"
monkeypatch.setitem(routes._cfg, "state_db_path", str(db_path))
monkeypatch.setitem(routes._cfg, "audit_path", str(tmp_path / "audit.jsonl"))
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph_api.json"))
r = client.post("/api/graph/node/update", json={
"node_id": "job_Java开发工程师",
"updates": {"title": "Java后端架构师", "level": "高级"},
"operator": "张老师", "reason": "校准"})
assert r.status_code == 200
node = r.json()["data"]["node"]
assert node["title"] == "Java后端架构师" and node["level"] == "高级"
assert node["expected_years"] == 6 # 级别同步回年限(高级→6)
# /api/jobs 的 JobDefinition 同步一致
d = client.get("/api/jobs/job_Java开发工程师").json()["data"]["definition"]
assert d["title"] == "Java后端架构师" and d["expected_years"] == 6
# 重建:节点编辑经覆写层重放保留,JobDefinition 同样一致
run_pipeline(cfg)
kg = TemporalKG.load(tmp_path / "graph.json")
assert "job_Java开发工程师" in kg.jobs # 2022 年出现 → 既有岗位正常重建
attrs = kg.g.nodes["job_Java开发工程师"]
assert attrs["title"] == "Java后端架构师" and attrs["level"] == "高级"
assert attrs["expected_years"] == 6
jd = kg.jobs["job_Java开发工程师"]
assert jd.title == "Java后端架构师" and jd.expected_years == 6
# ---------- 3. JD 手动导入(文件 / 文本 / 坏行) ----------
def _import_setup(tmp_path, monkeypatch):
import app.api.routes as routes
cfg = _tmp_cfg(tmp_path, JAVA_RECORDS)
monkeypatch.setitem(routes._cfg, "jd_raw_dir", cfg["jd_raw_dir"])
monkeypatch.setitem(routes._cfg, "graph_path", cfg["graph_path"])
monkeypatch.setitem(routes._cfg, "state_db_path", cfg["state_db_path"])
monkeypatch.setitem(routes._cfg, "audit_path", cfg["audit_path"])
monkeypatch.setitem(routes._cfg, "pending_path", cfg["pending_path"])
return cfg
def test_jd_import_text_paste(client, tmp_path, monkeypatch):
"""JSON 文本粘贴:合格记录落 import_*.jsonl(保留来源标记),坏行返回并写 logs,触发重建。"""
_import_setup(tmp_path, monkeypatch)
lines = [
json.dumps(_jd("n1", "Java开发工程师", "2025-03-01", ["Java"]), ensure_ascii=False),
"", # 空行 → 跳过
json.dumps(_jd("n2", "Python开发工程师", "2025-05-01", ["Python"]), ensure_ascii=False),
"{不是合法 json", # 坏行
]
r = client.post("/api/jd/import", json={"text": "\n".join(lines)})
assert r.status_code == 200
data = r.json()["data"]
assert data["imported"] == 2 and data["skipped"] == 1
assert len(data["bad_lines"]) == 1 and data["bad_lines"][0]["line"] == 4
jd_dir = tmp_path / "jd_raw"
imported = jd_dir / data["file"]
assert imported.exists() and data["file"].startswith("import_")
saved = [json.loads(x) for x in imported.read_text(encoding="utf-8").splitlines()]
assert {s["jid"] for s in saved} == {"n1", "n2"}
# 坏行写 logs
from app.config import ROOT
assert (ROOT / "logs" / data["bad_log"]).exists()
# 异步重建完成,导入统计随任务可查
t = _wait_task(client, data["task_id"])
assert t["status"] == "done" and t["import"]["imported"] == 2
def test_jd_import_file_upload(client, tmp_path, monkeypatch):
"""multipart 文件上传(.jsonl):与文本粘贴同一入库链路。"""
_import_setup(tmp_path, monkeypatch)
content = "\n".join([
json.dumps(_jd("f1", "Java开发工程师", "2025-01-10", ["Java"]), ensure_ascii=False),
json.dumps({"jid": "f2"}), # 缺必需字段 → 坏行
]).encode("utf-8")
r = client.post("/api/jd/import",
files={"file": ("extra.jsonl", content, "application/octet-stream")})
assert r.status_code == 200
data = r.json()["data"]
assert data["imported"] == 1 and len(data["bad_lines"]) == 1
assert data["bad_lines"][0]["line"] == 2
t = _wait_task(client, data["task_id"])
assert t["status"] == "done"
def test_jd_import_csv_and_bad_ext(client, tmp_path, monkeypatch):
"""CSV 表头映射导入;不支持的扩展名 400。"""
_import_setup(tmp_path, monkeypatch)
csv_text = ("jid,job_title,company,publish_date,text\n"
"c1,Java开发工程师,示例科技,2025-02-01,熟悉Java后端开发\n"
"c2,缺日期岗位,示例科技,,熟悉Python\n") # publish_date 缺失 → 坏行
r = client.post("/api/jd/import",
files={"file": ("jd.csv", csv_text.encode("utf-8"), "text/csv")})
assert r.status_code == 200
data = r.json()["data"]
assert data["imported"] == 1 and len(data["bad_lines"]) == 1
_wait_task(client, data["task_id"])
bad = client.post("/api/jd/import",
files={"file": ("jd.exe", b"MZ", "application/octet-stream")})
assert bad.status_code == 400
def test_jd_import_rebuild_only(client, tmp_path, monkeypatch):
"""不带内容调用保持旧行为:仅触发全量重建。"""
_import_setup(tmp_path, monkeypatch)
r = client.post("/api/jd/import", json={})
assert r.status_code == 200
data = r.json()["data"]
assert "imported" not in data and data["task_id"]
t = _wait_task(client, data["task_id"])
assert t["status"] == "done"
# ---------- 4. 访问令牌空串清除端到端 ----------
def test_token_clear_end_to_end(client, tmp_path, monkeypatch):
"""设置令牌 → 鉴权生效 → 空串清除 → 鉴权解除(config.json 同步)。"""
import app.config as cfgmod
import app.api.routes as routes
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json")
monkeypatch.setattr(routes, "load_runtime_settings", cfgmod.load_runtime_settings)
r = client.post("/api/config", json={"access_token": "tok-xyz"})
assert r.status_code == 200 and r.json()["data"]["has_access_token"] is True
assert client.get("/api/jobs").status_code == 401
assert client.get("/api/jobs", headers={"X-Access-Token": "tok-xyz"}).status_code == 200
# 空串清除(前端令牌框 dirty 后留空提交的场景)
r2 = client.post("/api/config", json={"access_token": ""},
headers={"X-Access-Token": "tok-xyz"})
assert r2.status_code == 200
assert r2.json()["data"]["has_access_token"] is False
assert "access_token" not in routes._cfg
assert "access_token" not in cfgmod.load_runtime_settings()
assert client.get("/api/jobs").status_code == 200 # 无需令牌即可访问
# ---------- 5. 语义权重相对路径 ----------
def test_relative_weights_path(tmp_path, monkeypatch):
"""embed_model_path 支持相对路径(相对工程根解析),绝对路径配置自动兼容。"""
import app.config as cfgmod
from app.match import semantic
monkeypatch.setattr(cfgmod, "ROOT", tmp_path)
w = tmp_path / "models" / "bge-m3"
w.mkdir(parents=True)
for f in ["config.json", "model.safetensors", "tokenizer.json"]:
(w / f).write_text("{}")
st = semantic.status({"embed_model_path": "models/bge-m3"})
assert st["configured"] is True and st["weights_present"] is True
# 既有绝对路径配置不受影响
st2 = semantic.status({"embed_model_path": str(w)})
assert st2["weights_present"] is True
assert semantic.resolve_weights_path("models/bge-m3") == w
assert semantic.resolve_weights_path(str(w)) == w
def test_accept_normalizes_candidate_id_single_node(client, tmp_path, monkeypatch):
"""候选转正归一化:accept 后全图只有 job_<岗位名> 一个节点,边/事件挂在其下。"""
import app.api.routes as routes
from app.services.overrides import normalize_job_id
from app.models import JobDefinition
kg = routes._get_kg()
job = JobDefinition(job_id="cand_测试岗位X", title="测试岗位X", category="测试",
required_skills=["Python"], preferred_skills=["Docker"],
scenarios=["教育"], industries=[], expected_years=3,
status="active", support_source_ids=["batch_2024.jsonl#1"],
support_windows=["2024Q1"])
assert normalize_job_id(job) == "job_测试岗位X"
job.job_id = normalize_job_id(job) # 调用方负责归一化(与 routes 一致)
from app.services.overrides import apply_accepted_job
apply_accepted_job(kg, job)
# 全图唯一节点:无 cand_ 残留,REQUIRES 边挂在归一化 id 下
assert "job_测试岗位X" in kg.g and "cand_测试岗位X" not in kg.g
assert kg.g.has_edge("job_测试岗位X", "Python")
assert not list(kg.g.edges("cand_测试岗位X"))
# 清理测试数据
kg.g.remove_node("job_测试岗位X")
kg.jobs.pop("job_测试岗位X", None)