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
+48
View File
@@ -0,0 +1,48 @@
"""pytest 共享夹具:确保数据与图谱存在(数据已随工程提交,缺失时现场生成)。"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
PY = sys.executable
@pytest.fixture(scope="session", autouse=True)
def ensure_data() -> None:
if not (ROOT / "data" / "jd_raw").exists():
subprocess.run([PY, str(ROOT / "scripts" / "generate_dataset.py")], check=True)
@pytest.fixture(scope="session")
def graph_path() -> Path:
path = ROOT / "data" / "graph.json"
if not path.exists():
subprocess.run([PY, str(ROOT / "scripts" / "run_demo.py")], check=True)
return path
@pytest.fixture(scope="session", autouse=True)
def isolated_state_db(tmp_path_factory):
"""整个测试会话把 SQLite 状态库重定向到临时目录,避免污染真实 data/careernexus.db。
audit_path/pending_path 仅作为旧文件迁移种子,指向不存在的临时路径即跳过迁移。
"""
from app.api import routes
tmp = tmp_path_factory.mktemp("state_db")
keys = ("state_db_path", "audit_path", "pending_path")
originals = {k: routes._cfg.get(k) for k in keys}
routes._cfg["state_db_path"] = str(tmp / "state.db")
routes._cfg["audit_path"] = str(tmp / "audit.jsonl")
routes._cfg["pending_path"] = str(tmp / "pending.json")
yield
for k, v in originals.items():
if v is None:
routes._cfg.pop(k, None)
else:
routes._cfg[k] = v
+427
View File
@@ -0,0 +1,427 @@
"""API 测试:TestClient 覆盖 health / jobs / graph / resume / match / learning-path / review。"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from app.api.routes import app
from app.config import ROOT
@pytest.fixture(scope="module")
def client(graph_path):
with TestClient(app) as c: # 触发 startup 加载图谱
yield c
def test_health(client):
r = client.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["code"] == 0 and body["request_id"]
assert body["data"]["graph"]["jobs"] >= 15
def test_graph_endpoint(client):
r = client.get("/api/graph")
assert r.status_code == 200
data = r.json()["data"]
assert len(data["nodes"]) > 100 and len(data["edges"]) > 100
types = {n["type"] for n in data["nodes"]}
assert "Job" in types and "Skill" in types
# 行业维度:Industry 节点与 (Job)-[:BELONGS_TO]->(Industry) 边
assert "Industry" in types
industries = {n["title"] for n in data["nodes"] if n["type"] == "Industry"}
assert {"人工智能", "互联网/软件", "金融科技", "智能制造"} <= industries
assert any(e.get("rel") == "BELONGS_TO" for e in data["edges"])
def test_jobs_list_and_filter(client):
r = client.get("/api/jobs")
assert r.status_code == 200
jobs = r.json()["data"]
assert len(jobs) >= 15
r2 = client.get("/api/jobs", params={"keyword": "Java"})
titles = [j["title"] for j in r2.json()["data"]]
assert titles == ["Java开发工程师"]
def test_job_detail_and_evolution(client):
r = client.get("/api/jobs/job_Java开发工程师")
assert r.status_code == 200
data = r.json()["data"]
assert data["definition"]["title"] == "Java开发工程师"
assert data["snapshot"]["skills"]
r2 = client.get("/api/jobs/job_Java开发工程师/evolution")
events = r2.json()["data"]["events"]
assert events and {"新增", "退出"} & {e["change_type"] for e in events}
assert all("数据源" in e["description"] for e in events)
assert client.get("/api/jobs/不存在").status_code == 404
def test_resume_parse_masks_sensitive(client):
text = "姓名:陈晓\n手机:13900001111\n邮箱:chenx@example.com\n4年经验,熟悉Java与MySQL"
r = client.post("/api/resume/parse", json={"text": text})
assert r.status_code == 200
p = r.json()["data"]
assert "13900001111" not in json.dumps(p, ensure_ascii=False)
assert "陈晓" not in json.dumps(p, ensure_ascii=False)
assert set(p["skills"]) == {"Java", "MySQL"}
def test_match_and_learning_path(client):
r = client.post("/api/match", json={
"job_id": "job_Java开发工程师",
"skills": ["Java", "Spring Boot", "Spring Cloud", "MySQL", "Redis", "分布式系统"],
"years": 5, "scenarios": ["电商", "金融"]})
assert r.status_code == 200
m = r.json()["data"]
assert m["is_match"] and m["total_score"] >= 60
assert m["dimension_scores"]["required"] == 100.0
r2 = client.post("/api/learning-path", json={
"job_id": "job_Java开发工程师", "known_skills": ["Java"]})
assert r2.status_code == 200
path = r2.json()["data"]["learning_path"]
assert path and "missing_required" in r2.json()["data"]
def test_review_flow(client, tmp_path, monkeypatch):
"""审核流:先造一个待审核候选,再 accept 写入图谱并留痕(SQLite 状态库重定向到临时目录)。"""
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
# 把状态库/迁移种子/图谱路径重定向到 tmp,避免污染工程内已提交数据
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.json"))
cand = JobDefinition(job_id="cand_测试新岗位", title="测试新岗位",
required_skills=["Python"], status="pending",
source_count=10, company_count=6)
save_pending([cand], db_path)
r = client.post("/api/review", json={
"candidate_id": "cand_测试新岗位", "action": "accept",
"operator": "张老师", "reason": "证据充分"})
assert r.status_code == 200
data = r.json()["data"]
assert data["accepted"]["status"] == "active"
assert data["review"]["before"]["status"] == "pending"
assert data["review"]["operator"] == "张老师"
# 审核后能在岗位列表查到
assert client.get("/api/jobs/job_测试新岗位").status_code == 200
# 留痕落在 SQLite(结构与旧 jsonl 行一致),候选已从待审核分区移除
recs = state_db.get_audit(db_path)
assert recs and recs[0]["operator"] == "张老师" and recs[0]["action"] == "accept"
assert state_db.get_candidate(db_path, "cand_测试新岗位") is None
# /api/audit 兼容查询
r2 = client.get("/api/audit", params={"node_id": "cand_测试新岗位"})
assert r2.status_code == 200 and r2.json()["data"][0]["candidate_id"] == "cand_测试新岗位"
# 不存在的候选返回 404
assert client.post("/api/review", json={
"candidate_id": "cand_不存在", "action": "accept", "operator": "x"}).status_code == 404
def test_node_update_flow(client, tmp_path, monkeypatch):
"""节点编辑:白名单字段更新 + 图谱落盘 + 审核留痕(SQLite 状态库重定向到临时目录)。"""
import app.api.routes as routes
monkeypatch.setitem(routes._cfg, "graph_path", str(tmp_path / "graph.json"))
monkeypatch.setitem(routes._cfg, "state_db_path", str(tmp_path / "state.db"))
prev_nodes = client.get("/api/graph").json()["data"]["nodes"]
prev_level = next(n for n in prev_nodes if n["id"] == "job_Java开发工程师").get("level")
r = client.post("/api/graph/node/update", json={
"node_id": "job_Java开发工程师",
"updates": {"level": "高级", "weight": 0.9, "hack": "ignored"},
"operator": "张老师", "reason": "webui 校正"})
assert r.status_code == 200
data = r.json()["data"]
assert data["node"]["level"] == "高级" and data["node"]["weight"] == 0.9
assert "hack" not in data["node"]
assert data["audit"]["before"]["level"] == prev_level
# Job 节点编辑同步 JobDefinition:级别同步回期望年限,/api/jobs 一致
assert data["node"]["expected_years"] == 6
d = client.get("/api/jobs/job_Java开发工程师").json()["data"]["definition"]
assert d["expected_years"] == 6
assert (tmp_path / "graph.json").exists()
# 留痕可查
r2 = client.get("/api/audit", params={"node_id": "job_Java开发工程师"})
assert r2.status_code == 200
recs = r2.json()["data"]
assert recs and recs[0]["operator"] == "张老师"
# 不存在的节点 / 空更新
assert client.post("/api/graph/node/update", json={
"node_id": "不存在", "updates": {"level": "x"}, "operator": "x"}).status_code == 404
assert client.post("/api/graph/node/update", json={
"node_id": "job_Java开发工程师", "updates": {"hack": 1}, "operator": "x"}).status_code == 400
def test_jd_lookup(client):
"""JD 原文回查:合法 source_id 返回记录,非法输入 400/404。"""
g = client.get("/api/graph").json()["data"]
sid = next(s for e in g["edges"] for s in e.get("sources", []))
r = client.get("/api/jd", params={"sid": sid})
assert r.status_code == 200
rec = r.json()["data"]
assert rec["source_id"] == sid and rec["text"] and rec["job_title"]
assert client.get("/api/jd", params={"sid": "无行号"}).status_code == 400
assert client.get("/api/jd", params={"sid": "../evil.jsonl#1"}).status_code == 400
assert client.get("/api/jd", params={"sid": "batch_2019.jsonl#99999"}).status_code == 404
def _minimal_pdf(text: str) -> bytes:
"""构造含可提取文本的最小合法 PDF(ASCII 内容)。"""
stream = f"BT /F1 12 Tf 72 720 Td ({text}) Tj ET".encode()
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream",
]
out = b"%PDF-1.4\n"
offsets = []
for i, body in enumerate(objs, 1):
offsets.append(len(out))
out += f"{i} 0 obj\n".encode() + body + b"\nendobj\n"
xref_pos = len(out)
out += f"xref\n0 {len(objs)+1}\n".encode() + b"0000000000 65535 f \n"
for off in offsets:
out += f"{off:010d} 00000 n \n".encode()
out += (f"trailer\n<< /Size {len(objs)+1} /Root 1 0 R >>\n"
f"startxref\n{xref_pos}\n%%EOF").encode()
return out
def test_resume_file_upload_docx_and_pdf(client, tmp_path):
"""简历文件上传:docx/pdf 均解析出技能并脱敏,非法格式与超限被拒。"""
from docx import Document
# docx:python-docx 真实生成
doc = Document()
doc.add_paragraph("姓名:李晓峰 手机:13912345678")
doc.add_paragraph("6年 Java 后端经验,熟悉 Java、Spring Boot、MySQL、Redis")
p = tmp_path / "r.docx"
doc.save(str(p))
r = client.post("/api/resume/parse/file",
files={"file": ("r.docx", p.read_bytes(), "application/octet-stream")})
assert r.status_code == 200
data = r.json()["data"]
assert "Java" in data["skills"] and "MySQL" in data["skills"]
assert "13912345678" not in data["raw_masked_text"] # 已脱敏
assert data["name"].startswith("李") and "晓峰" not in data["name"]
# pdf:最小合法 PDF(ASCII 技能名,验证 pypdf 链路)
pdf = _minimal_pdf("Java Spring Boot MySQL Redis Docker Kubernetes")
r2 = client.post("/api/resume/parse/file",
files={"file": ("r.pdf", pdf, "application/pdf")})
assert r2.status_code == 200
skills2 = r2.json()["data"]["skills"]
assert "Java" in skills2 and "Kubernetes" in skills2
# txt 也走同一入口
r3 = client.post("/api/resume/parse/file",
files={"file": ("r.txt", "熟悉 Python 与 TensorFlow".encode(), "text/plain")})
assert r3.status_code == 200 and "Python" in r3.json()["data"]["skills"]
# 非法格式 / 空文件
bad = client.post("/api/resume/parse/file",
files={"file": ("r.exe", b"MZ", "application/octet-stream")})
assert bad.status_code == 400
empty = client.post("/api/resume/parse/file",
files={"file": ("r.pdf", b"", "application/pdf")})
assert empty.status_code == 400
def test_match_normalizes_manual_skills(client):
"""手动录入技能经大小写/别名归一化后再匹配:redis→Redis、go→Go、K8s→Kubernetes。"""
r = client.post("/api/match", json={
"job_id": "job_Go开发工程师",
"skills": ["go", "redis", "grpc", "K8s", "kafka", "微服务"],
"years": 5})
assert r.status_code == 200
m = r.json()["data"]
hit = set(m["matched_required"]) | set(m["matched_preferred"])
assert {"Go", "Redis", "gRPC", "Kubernetes", "Kafka", "微服务"} <= hit
assert "redis" not in hit and "K8s" not in hit # 返回的是标准名
def test_skill_normalize_semantic_fallback():
"""normalize 三级容错:别名→包含匹配(取最长)→原样返回。"""
from app.extract.base import SkillMatcher, load_lexicon
m = SkillMatcher(load_lexicon())
assert m.normalize("redis") == "Redis" # 大小写
assert m.normalize("K8s") == "Kubernetes" # 别名
assert m.normalize("微服务架构") == "微服务" # 中文包含
assert m.normalize("Java语言") == "Java" # 后缀容错
assert m.normalize("JavaScript") == "JavaScript" # 最长优先,不被 Java 截胡
assert m.normalize("222") == "222" # 词表外原样返回
def test_resume_structured_and_export(client):
"""结构化简历:上传 docx 提取照片/基本信息/分节;导出 DOCX 合法且含关键词。"""
from app.config import ROOT as _ROOT
p = _ROOT / "data" / "resumes" / "resume_full.docx"
r = client.post("/api/resume/parse/file",
files={"file": ("resume_full.docx", p.read_bytes(), "application/octet-stream")})
assert r.status_code == 200
prof = r.json()["data"]
assert prof["photo_b64"].startswith("data:image/") # 照片识别
assert prof["gender"] == "男" and prof["education"] == "本科"
assert prof["address"].endswith("***") # 住址脱敏
titles = [s["title"] for s in prof["sections"]]
assert "教育经历" in titles and "项目经历" in titles # 分节
assert "13812345678" not in prof["raw_masked_text"] # 手机脱敏
# 匹配后导出 DOCX
m = client.post("/api/match", json={
"job_id": "job_Java开发工程师",
"skills": prof["skills"], "years": prof["years"]}).json()["data"]
r2 = client.post("/api/resume/export", json={"profile": prof, "match": m})
assert r2.status_code == 200
assert r2.headers["content-type"].startswith("application/vnd.openxmlformats")
import io, zipfile
z = zipfile.ZipFile(io.BytesIO(r2.content))
assert "word/document.xml" in z.namelist() # 合法 docx
assert any(n.startswith("word/media/") for n in z.namelist()) # 照片已嵌入
def test_runtime_settings_and_token_guard(client, monkeypatch, tmp_path):
"""设置读写 config.json(白名单+校验)、LLM 热生效判定、访问令牌鉴权。"""
import app.config as cfgmod
from app.api import routes
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json")
monkeypatch.setattr(routes, "load_runtime_settings", cfgmod.load_runtime_settings)
# 保存设置:host/port 校验
assert client.post("/api/config", json={"host": "8.8.8.8"}).status_code == 400
assert client.post("/api/config", json={"port": 80}).status_code == 400
r = client.post("/api/config", json={
"host": "0.0.0.0", "port": 9012, "llm_api_key": "sk-test-1234", "mode": "demo"})
assert r.status_code == 200
data = r.json()["data"]
assert data["restart_required"] is True # 保存的监听与运行中不同
assert data["llm"]["configured"] is True # LLM 热生效
assert data["llm"]["api_key_masked"].endswith("1234")
assert "sk-test-1234" not in r.text # 密钥不落明文
saved = cfgmod.load_runtime_settings()
assert saved["llm_api_key"] == "sk-test-1234" and saved["port"] == 9012
# 非白名单键不写入
client.post("/api/config", json={})
assert "hack" not in cfgmod.load_runtime_settings()
# 令牌鉴权:设置令牌后 /api/* 需带 X-Access-Token,health 与 GET config 豁免
monkeypatch.setitem(routes._cfg, "access_token", "tok-abc")
assert client.get("/api/jobs").status_code == 401
assert client.get("/api/jobs", headers={"X-Access-Token": "tok-abc"}).status_code == 200
assert client.get("/api/health").status_code == 200
assert client.get("/api/config").status_code == 200
assert client.post("/api/config", json={}).status_code == 401
monkeypatch.delitem(routes._cfg, "access_token")
def test_get_extractor_llm_key_from_config():
"""get_extractor:config.json 提供 llm_api_key 即启用 LLM 通道,无密钥回退离线。"""
from app.extract.llm import LLMExtractor, get_extractor
from app.extract.offline import OfflineExtractor
e1 = get_extractor({"llm_api_key": "sk-x", "llm_base_url": "http://x/v1",
"llm_model": "m", "lexicon_path": None})
assert isinstance(e1, LLMExtractor)
e2 = get_extractor({"lexicon_path": None, "llm_api_key_env": "NO_SUCH_ENV_VAR"})
assert isinstance(e2, OfflineExtractor)
def test_embed_model_status_and_config(client, tmp_path, monkeypatch):
"""语义权重状态:未配置/假目录/缺依赖时 ready=False 且状态字段正确;设置可保存。"""
import app.config as cfgmod
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json") # 不写真实 config.json
from app.match import semantic
assert not semantic.weights_present("") and not semantic.weights_present(tmp_path)
# 伪造一个"文件齐全"的权重目录
for f in ["config.json", "model.safetensors", "tokenizer.json"]:
(tmp_path / f).write_text("{}")
assert semantic.weights_present(tmp_path)
st = semantic.status({"embed_model_path": str(tmp_path)})
assert st["configured"] and st["weights_present"]
assert st["ready"] == st["deps_installed"] # 本环境未装 sentence-transformers → False
r = client.post("/api/config", json={"embed_model_path": str(tmp_path)})
assert r.status_code == 200
emb = r.json()["data"]["embed"]
assert emb["configured"] and emb["weights_present"]
# health 同步暴露
h = client.get("/api/health").json()["data"]
assert h["embed"]["configured"] is True
# 清除
r2 = client.post("/api/config", json={"embed_model_path": ""})
assert r2.json()["data"]["embed"]["configured"] is False
def test_embed_dual_provider(client, tmp_path, monkeypatch):
"""语义双模式:api 模式有 Key 即就绪(无需权重);local 模式需权重+依赖。"""
import app.config as cfgmod
monkeypatch.setattr(cfgmod, "CONFIG_JSON", tmp_path / "config.json") # 不写真实 config.json
from app.match import semantic
st_api = semantic.status({"embed_provider": "api", "embed_api_key": "sk-1234"})
assert st_api["ready"] is True and st_api["provider"] == "api"
st_local = semantic.status({"embed_provider": "local", "embed_model_path": ""})
assert st_local["ready"] is False
# most_similar 在未就绪时安全返回 None
assert semantic.most_similar("微服务", {"embed_provider": "local"}, {"skills": [{"name": "微服务"}]}) is None
r = client.post("/api/config", json={
"embed_provider": "api", "embed_api_key": "sk-dash-9999"})
emb = r.json()["data"]["embed"]
assert emb["ready"] is True and emb["api_key_masked"].endswith("9999")
assert "sk-dash-9999" not in r.text
# 还原:回到本地模式并清除 key
r2 = client.post("/api/config", json={"embed_provider": "local", "embed_api_key": ""})
assert r2.json()["data"]["embed"]["ready"] is False
def test_match_batch(client):
"""批量匹配:多份简历打分降序、坏文件记 error 不拖垮整批。"""
from app.config import ROOT as _ROOT
resumes = _ROOT / "data" / "resumes"
files = []
for fn in ["resume_full.txt", "resume_partial.txt", "resume_mismatch.txt"]:
files.append(("files", (fn, (resumes / fn).read_bytes(), "text/plain")))
files.append(("files", ("bad.exe", b"MZ", "application/octet-stream")))
r = client.post("/api/match/batch",
data={"job_id": "job_Java开发工程师"}, files=files)
assert r.status_code == 200
data = r.json()["data"]
assert data["total"] == 4 and data["ok"] == 3
scores = [x["total_score"] for x in data["results"] if "error" not in x]
assert scores == sorted(scores, reverse=True) # 按总分降序
assert any("error" in x for x in data["results"]) # 坏文件有错误记录
top = data["results"][0]
assert top["matched"] and top["name"] # 第一名有命中与脱敏姓名
assert client.post("/api/match/batch",
data={"job_id": "不存在"}, files=files[:1]).status_code == 404
def test_semantic_setup_endpoints(client, monkeypatch):
"""一键配置:状态接口默认空闲;已就绪时幂等快速完成;重复启动不并发。"""
import app.api.routes as routes
from app.config import ROOT as _ROOT
# 让运行配置处于"语义已就绪"状态,命中幂等快路径(避免测试里真跑 pip)
monkeypatch.setitem(routes._cfg, "embed_provider", "local")
monkeypatch.setitem(routes._cfg, "embed_model_path", str(_ROOT / "models" / "bge-m3"))
routes._semantic_setup.update({"running": False, "stage": "", "log": [], "error": "", "done": False})
st = client.get("/api/semantic/setup/status").json()["data"]
assert st["running"] is False and "embed" in st
r = client.post("/api/semantic/setup")
assert r.status_code == 200
import time
st2 = st
for _ in range(50):
st2 = client.get("/api/semantic/setup/status").json()["data"]
if not st2["running"]:
break
time.sleep(0.2)
assert st2["done"] is True and st2["error"] == ""
assert st2["embed"]["ready"] is True
+76
View File
@@ -0,0 +1,76 @@
"""清洗层测试:植入的抄袭簇必须被检出、噪声过滤、时间衰减公式。"""
from __future__ import annotations
import json
import math
from datetime import date
from pathlib import Path
import pytest
from app.cleaning.decay import apply_decay, decay_weight
from app.cleaning.dedup import MinHash, find_near_duplicates, shingles
from app.cleaning.noise import filter_noise, is_noise
from app.ingest.loader import load_jd_dir
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture(scope="module")
def cleaned():
records, errors = load_jd_dir(ROOT / "data" / "jd_raw")
filter_noise(records)
clusters = find_near_duplicates(records)
meta = json.loads((ROOT / "data" / "jd_raw" / "_meta.json").read_text(encoding="utf-8"))
return records, errors, clusters, meta
def test_minhash_identical_text_similarity_one():
mh = MinHash()
s1 = mh.signature(shingles("熟练掌握Java与Spring Cloud,负责微服务开发"))
s2 = mh.signature(shingles("熟练掌握Java与Spring Cloud,负责微服务开发"))
assert mh.estimate(s1, s2) == 1.0
def test_minhash_distinguishes_different_text():
mh = MinHash()
s1 = mh.signature(shingles("熟练掌握Java与Spring Cloud,负责微服务开发"))
s2 = mh.signature(shingles("精通Verilog与FPGA,负责芯片逻辑设计与验证"))
assert mh.estimate(s1, s2) < 0.3
def test_planted_dup_clusters_detected(cleaned):
"""植入的抄袭簇必须全部被检出:副本被降权且指向最早来源。"""
records, _, clusters, meta = cleaned
by_jid = {r.jid: r for r in records}
assert len(clusters) >= len(meta["dup_groups"]) * 0.9
for keeper_jid, copy_jid in meta["dup_groups"]:
copy = by_jid[copy_jid]
assert copy.is_duplicate, f"抄袭副本未被检出: {copy_jid}"
assert copy.dup_of == keeper_jid
assert copy.weight < by_jid[keeper_jid].weight # 副本降权、最早来源保留
def test_planted_noise_detected(cleaned):
"""植入噪声:整条广告被标记,混排尾注被剔除。"""
records, _, _, meta = cleaned
by_jid = {r.jid: r for r in records}
for jid in meta["noise_jids"]:
rec = by_jid[jid]
assert rec.is_noise or not is_noise(rec.text), f"噪声未处理: {jid}"
def test_decay_formula():
w = decay_weight(date(2020, 1, 1), date(2026, 1, 1), lam=0.35)
assert abs(w - math.exp(-0.35 * (2191 / 365.25))) < 1e-3
assert decay_weight(date(2026, 1, 1), date(2026, 1, 1)) == 1.0
older = decay_weight(date(2019, 6, 1), date(2026, 6, 1))
newer = decay_weight(date(2025, 6, 1), date(2026, 6, 1))
assert older < newer # 越旧权重越低(时滞衰减)
def test_apply_decay_multiplies_existing(cleaned):
records, _, _, _ = cleaned
apply_decay(records, ref_date=date(2026, 12, 28), lam=0.35)
for r in records:
assert 0 < r.weight <= 1.0
+68
View File
@@ -0,0 +1,68 @@
"""演化分析测试:构造两季度(多窗口)数据验证 新增/增强/减弱/退出 事件。"""
from __future__ import annotations
from app.evolution.analyzer import analyze
from app.graph.tkg import TemporalKG
from app.models import GraphObservation, JobDefinition
def _obs(job: str, skill: str, window: str, n: int) -> list[GraphObservation]:
"""生成 n 条同窗口观察(模拟窗口内 n 条 JD 提到该技能)。"""
return [GraphObservation(job_id=job, skill=skill, importance="required",
weight=1.0, window=window, source_ids=[f"{window}#{i}"])
for i in range(n)]
def test_quarter_window_events():
kg = TemporalKG()
kg.add_job(JobDefinition(job_id="job_A", title="岗位A"))
observations: list[GraphObservation] = []
# 老技能 Legacy:2024Q1-Q3 稳定出现(3 窗口),Q4 与次年 Q1 连续消失 → 退出
for w in ["2024Q1", "2024Q2", "2024Q3"]:
observations += _obs("job_A", "Legacy技能", w, 3)
# 新技能 NewSkill:2024Q4 首次出现且支撑 >=2 → 新增
observations += _obs("job_A", "NewSkill", "2024Q4", 3)
# 增强技能:3 → 9(>=1.5 倍且差 >=3)
observations += _obs("job_A", "GrowSkill", "2024Q3", 3)
observations += _obs("job_A", "GrowSkill", "2024Q4", 9)
# 减弱技能:8 → 3(<=0.5 倍且差 >=3)
observations += _obs("job_A", "ShrinkSkill", "2024Q3", 8)
observations += _obs("job_A", "ShrinkSkill", "2024Q4", 3)
for o in observations: # 与 pipeline 一致:观察先写入图谱,再分析演化
kg.observe(o)
events = analyze(kg, observations=observations)
by_type = {}
for e in events:
by_type.setdefault(e.change_type, []).append(e)
# 新增:LegacySkill 首现 + NewSkill 首现
new_skills = {e.skill for e in by_type.get("新增", [])}
assert "NewSkill" in new_skills and "Legacy技能" in new_skills
# 退出:Legacy 技能在 2024Q4 判定(Q4、次年 Q1 连续为 0,且已稳定 3 窗口)
exits = [e for e in by_type.get("退出", []) if e.skill == "Legacy技能"]
assert exits and exits[0].window == "2024Q4"
assert exits[0].prev_weight > 0 and exits[0].curr_weight == 0
assert "数据源" in exits[0].description # 中文说明注明数据源
# 增强 / 减弱
assert any(e.skill == "GrowSkill" and e.window == "2024Q4"
for e in by_type.get("增强", []))
assert any(e.skill == "ShrinkSkill" and e.window == "2024Q4"
for e in by_type.get("减弱", []))
# 退出事件关闭了图谱区间
assert kg.g["job_A"]["Legacy技能"]["intervals"][-1][1] == "2024Q4"
# 事件保存前后权重、时间窗、来源
grow = next(e for e in by_type["增强"] if e.skill == "GrowSkill")
assert grow.prev_weight == 3.0 and grow.curr_weight == 9.0
assert grow.source_ids
def test_no_spurious_exit_for_new_skill():
"""只出现 1-2 个窗口的技能消失不报“退出”(抑制稀疏噪声)。"""
kg = TemporalKG()
kg.add_job(JobDefinition(job_id="job_B", title="岗位B"))
observations = _obs("job_B", "一次性技能", "2024Q1", 3) # 只出现 1 个窗口
observations += _obs("job_B", "常驻技能", "2024Q1", 2)
observations += _obs("job_B", "常驻技能", "2024Q2", 2)
events = analyze(kg, observations=observations)
assert not [e for e in events if e.change_type == "退出" and e.skill == "一次性技能"]
+131
View File
@@ -0,0 +1,131 @@
"""抽取层测试:Schema 校验、坏 JSON 重试与降级、多源投票、离线抽取与别名归一。"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.extract.base import SkillMatcher, load_lexicon, vote_field
from app.extract.llm import LLMExtractor
from app.extract.offline import OfflineExtractor
from app.models import ExtractionResult, JDRecord
SAMPLE_TEXT = """岗位名称:Java开发工程师
某科技公司诚邀优秀人才加入。
岗位职责:
1、负责后端服务的设计与开发;
2、参与核心系统的性能优化;
任职要求:
1、3年以上相关工作经验;
2、熟练掌握Java、Spring Boot、MySQL、Redis;
3、具有电商或金融行业经验者优先;
加分项:
1、了解Docker、Kubernetes者优先;
"""
def _record(text: str = SAMPLE_TEXT) -> JDRecord:
return JDRecord(jid="X1", job_title="Java开发工程师", company="某科技",
publish_date="2024-05-01", text=text)
def test_offline_extract_fields_and_evidence():
ext = OfflineExtractor(load_lexicon())
r = ext.extract(_record())
assert r.job_title == "Java开发工程师"
assert set(r.required_skills) == {"Java", "Spring Boot", "MySQL", "Redis"}
assert set(r.preferred_skills) == {"Docker", "Kubernetes"}
assert r.responsibilities == ["负责后端服务的设计与开发;", "参与核心系统的性能优化;"]
assert set(r.scenarios) == {"电商", "金融"}
assert r.evidence["required_skills"] # 每字段附证据片段
assert r.confidence == 1.0
def test_alias_normalization():
matcher = SkillMatcher(load_lexicon())
text = "要求LLM应用开发经验,熟悉K8s与SpringBoot,了解检索增强生成"
names = matcher.find_names(text)
assert "大模型应用开发" in names # LLM应用开发 归一化
assert "Kubernetes" in names # K8s 归一化
assert "Spring Boot" in names # SpringBoot 归一化
assert "RAG" in names # 检索增强生成 归一化
def test_tail_section_not_mixed_into_preferred():
"""尾部“团队与福利/福利待遇”段落不属于加分区:其中的技能词不得混入加分技能。"""
text = SAMPLE_TEXT + "团队与福利:\n团队标配Kubernetes与Terraform,提供外部培训预算。\n"
ext = OfflineExtractor(load_lexicon())
r = ext.extract(_record(text))
assert set(r.preferred_skills) == {"Docker", "Kubernetes"}
assert "Terraform" not in r.preferred_skills
def test_java_not_confused_with_javascript():
matcher = SkillMatcher(load_lexicon())
names = matcher.find_names("精通JavaScript与Vue,不写Java后端")
assert "JavaScript" in names
assert "Java" in names # “Java后端”中的 Java 是独立词
only_js = matcher.find_names("精通JavaScript与Vue")
assert "Java" not in only_js
def test_schema_validation_rejects_bad_payload():
with pytest.raises(ValidationError):
ExtractionResult(job_title="X", required_skills="不是列表")
with pytest.raises(ValidationError):
ExtractionResult(job_title="X", confidence="不是数字")
def _make_llm() -> LLMExtractor:
return LLMExtractor(api_key="fake", base_url="http://localhost:1", model="m",
timeout=0.1, fallback=OfflineExtractor(load_lexicon()))
def test_llm_bad_json_retries_then_success(monkeypatch):
"""坏 JSON 触发重试,第 3 次返回合法 JSON 则成功。"""
llm = _make_llm()
calls = {"n": 0}
good = ('{"job_title": "Java开发工程师", "responsibilities": [], '
'"required_skills": ["Java"], "preferred_skills": [], "scenarios": []}')
def fake_call(text):
calls["n"] += 1
return "{坏JSON" if calls["n"] < 3 else good
monkeypatch.setattr(llm, "_call_api", fake_call)
r = llm.extract(_record())
assert calls["n"] == 3
assert r.extractor == "llm"
assert r.required_skills == ["Java"]
def test_llm_all_fail_fallback_offline(monkeypatch):
"""重试耗尽(含超时/坏 JSON)降级到离线抽取,链路不断。"""
llm = _make_llm()
monkeypatch.setattr(llm, "_call_api", lambda text: (_ for _ in ()).throw(TimeoutError("超时")))
r = llm.extract(_record())
assert r.extractor == "offline"
assert "Java" in r.required_skills
def test_llm_schema_invalid_retries(monkeypatch):
"""返回 JSON 合法但不符合 Schema(类型错误)同样重试并降级。"""
llm = _make_llm()
monkeypatch.setattr(llm, "_call_api",
lambda text: '{"job_title": 123, "required_skills": "wrong"}')
r = llm.extract(_record())
assert r.extractor == "offline"
def test_vote_field_requires_agreement():
"""关键字段多源一致才入选:A 三源全票,B 两票,C 一票出局。"""
chosen, conf = vote_field([
(["A", "B", "C"], 1.0),
(["A", "B"], 1.0),
(["A"], 1.0),
], min_agreement=0.5)
assert chosen == ["A", "B"]
assert conf == 1.0
chosen2, _ = vote_field([(["A"], 1.0), (["B"], 1.0)], min_agreement=0.5)
assert chosen2 == ["A", "B"] # 平票 0.5 恰好达到门槛
chosen3, conf3 = vote_field([(["A"], 1.0), (["B"], 1.0), (["C"], 1.0)], min_agreement=0.5)
assert chosen3 == []
+93
View File
@@ -0,0 +1,93 @@
"""匹配引擎测试:评分权重、差距清单、可迁移技能、学习路径拓扑序、简历脱敏。"""
from __future__ import annotations
from app.extract.base import load_lexicon
from app.match.engine import MatchEngine, topological_stages
from app.match.resume import mask_text, parse_resume
from app.models import JobDefinition, ResumeProfile
JOB = JobDefinition(
job_id="job_X", title="X工程师", category="后端开发",
required_skills=["Java", "Spring Boot", "MySQL", "Redis"],
preferred_skills=["Docker", "Kubernetes"],
scenarios=["电商", "金融"], expected_years=3,
)
def _engine(**weights) -> MatchEngine:
return MatchEngine(lexicon=load_lexicon(), weights=weights or None)
def test_scoring_weights_and_dimensions():
"""四维加权:必备50/加分15/经验20/场景15。"""
profile = ResumeProfile(
skills=["Java", "Spring Boot", "Docker"], # 必备 2/4,加分 1/2
years=1.5, # 经验 1.5/3 = 0.5
scenarios=["电商"], # 场景 1/2
)
r = _engine().match(profile, JOB)
assert r.dimension_scores == {"required": 50.0, "preferred": 50.0,
"experience": 50.0, "scenario": 50.0}
# 总分 = 0.5*50 + 0.15*50 + 0.2*50 + 0.15*50 = 50
assert r.total_score == 50.0
assert not r.is_match # < 60 阈值
def test_custom_weights_override():
profile = ResumeProfile(skills=["Java", "Spring Boot", "MySQL", "Redis"],
years=3, scenarios=["电商", "金融"])
r = _engine(required=1.0, preferred=0.0, experience=0.0, scenario=0.0).match(profile, JOB)
assert r.total_score == 100.0 and r.is_match
def test_gap_list_and_transferable():
profile = ResumeProfile(skills=["Java", "Spring", "MySQL"], years=3,
scenarios=["电商", "金融"])
r = _engine().match(profile, JOB)
assert r.missing_required == ["Spring Boot", "Redis"]
assert r.matched_required == ["Java", "MySQL"]
# Spring 与缺失的 Spring Boot 同属“后端框架”→ 可迁移
assert "Spring" in r.transferable_skills
def test_learning_path_topological_order():
"""缺失技能按前置关系拓扑分层:Spring → Spring Boot → Spring Cloud。"""
lexicon = load_lexicon()
stages = topological_stages(["Spring Cloud", "Spring Boot", "Spring"], lexicon)
assert stages[0]["skills"] == ["Spring"]
assert stages[1]["skills"] == ["Spring Boot"]
assert stages[2]["skills"] == ["Spring Cloud"]
# 全部缺失时拓扑序稳定且不漏项
stages2 = topological_stages(["Kubernetes", "Docker", "Linux"], lexicon)
flat = [s for st in stages2 for s in st["skills"]]
assert flat == ["Linux", "Docker", "Kubernetes"]
def test_learning_path_acceptance():
"""每个阶段附带结构化验收标准:产出物/量化验收项/自测问答,且按技能类别差异化。"""
lexicon = load_lexicon()
stages = topological_stages(["Spring Cloud", "Spring Boot", "Spring"], lexicon)
for st in stages:
acc = st["acceptance"]
assert acc["deliverable"] and len(acc["criteria"]) >= 3 and len(acc["self_check"]) >= 2
assert all("{" not in c for c in acc["criteria"] + acc["self_check"]) # 占位符全部替换
assert any(s in acc["deliverable"] for s in st["skills"]) # 产出物点名本阶段技能
# 类别差异化:后端框架类与产品类的验收内容应不同
be = topological_stages(["Spring"], lexicon)[0]["acceptance"]
pm = topological_stages(["A/B测试"], lexicon)[0]["acceptance"]
assert be["category"] != pm["category"] or be["criteria"] != pm["criteria"]
def test_resume_masking():
text = "姓名:王小明\n手机:13812345678\n邮箱:wxm@example.com\n3年开发经验"
masked = mask_text(text)
assert "王小明" not in masked and "王**" in masked
assert "13812345678" not in masked and "138****78" in masked
assert "wxm@example.com" not in masked and "w***@example.com" in masked
def test_resume_parse_skills_and_years():
profile = parse_resume("姓名:李四\n5年后端开发经验,熟悉Java、MySQL与Docker。")
assert profile.years == 5.0
assert set(profile.skills) == {"Java", "MySQL", "Docker"}
assert "138" not in profile.raw_masked_text
+373
View File
@@ -0,0 +1,373 @@
"""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)
+102
View File
@@ -0,0 +1,102 @@
"""data/real 真实数据集:文件齐全、gold Schema 合法、批量演示素材已脱敏、真实评测维度可运行。"""
from __future__ import annotations
import importlib.util
import json
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
REAL = ROOT / "data" / "real"
JD_DIR = REAL / "autoweaks_jd"
RESUME_DIR = REAL / "aistudio_resumes"
DEMO_DIR = REAL / "batch_demo_resumes"
ESCO_DIR = REAL / "esco_reference"
GOLD_JD_FIELDS = ("job_title", "responsibilities", "required_skills",
"preferred_skills", "scenarios")
def _load_evaluate():
"""按路径加载 scripts/evaluate.py(scripts 不是包)。"""
spec = importlib.util.spec_from_file_location("evaluate", ROOT / "scripts" / "evaluate.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_real_tree_complete() -> None:
assert (REAL / "README.md").exists()
assert (JD_DIR / "jd_sample.jsonl").exists()
assert (JD_DIR / "gold_answers.json").exists()
assert len(list(RESUME_DIR.glob("*.txt"))) == 50
assert (RESUME_DIR / "gold.json").exists()
assert len(list(DEMO_DIR.glob("*.txt"))) == 12
for name in ("labels_isco_codes.txt", "labels_isco_labels.txt",
"labels_esco_code.txt", "labels_esco_occupation.txt", "README.md"):
assert (ESCO_DIR / name).exists(), name
def test_jd_gold_schema() -> None:
records = [json.loads(ln) for ln in
(JD_DIR / "jd_sample.jsonl").read_text(encoding="utf-8").splitlines() if ln.strip()]
assert len(records) == 200
gold = json.loads((JD_DIR / "gold_answers.json").read_text(encoding="utf-8"))
assert {r["jid"] for r in records} == set(gold)
for r in records:
for key in ("jid", "job_title", "company", "publish_date", "source", "url", "text"):
assert key in r, (r["jid"], key)
assert r["source"] == "autoweaks"
assert r["company"] in ("腾讯", "京东")
assert re.fullmatch(r"2019-\d{2}-\d{2}", r["publish_date"]), r["jid"]
for jid, g in gold.items():
assert tuple(g) == GOLD_JD_FIELDS or set(g) == set(GOLD_JD_FIELDS), jid
assert isinstance(g["job_title"], str) and g["job_title"]
for f in ("responsibilities", "required_skills", "preferred_skills", "scenarios"):
assert isinstance(g[f], list) and all(isinstance(s, str) for s in g[f]), (jid, f)
def test_resume_gold_schema() -> None:
gold = json.loads((RESUME_DIR / "gold.json").read_text(encoding="utf-8"))
assert len(gold) == 50
for fname, g in gold.items():
assert (RESUME_DIR / fname).exists(), fname
assert re.fullmatch(r"[一-龥]{2,4}", g["name"]), fname
assert re.fullmatch(r"1[3-9]\d{9}", g["phone"]), fname
assert isinstance(g["skills"], list) and all(isinstance(s, str) for s in g["skills"])
def test_batch_demo_masked() -> None:
"""批量演示素材必须已脱敏:无 11 位手机号、邮箱仅呈 x***@domain 形态、姓名带 *。"""
files = list(DEMO_DIR.glob("*.txt"))
assert len(files) == 12
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})") # 未脱敏姓名(脱敏后含 *)
for f in files:
text = f.read_text(encoding="utf-8")
assert not phone_re.search(text), f.name
assert not email_re.search(text), f.name # 脱敏形态 x***@d.com 不匹配该正则
assert not name_re.search(text), f.name
assert "****" in text, f.name # 手机号已打码
assert re.search(r"姓名[::]\s*[一-龥]\*", text), f.name
@pytest.mark.parametrize("dim", ["real_jd", "real_resume"])
def test_eval_real_dimensions(dim: str) -> None:
"""真实维度评测可运行且指标在合法区间(全量小数据集,直接实跑)。"""
evaluate = _load_evaluate()
result = evaluate.eval_real_jd() if dim == "real_jd" else evaluate.eval_real_resume()
assert 0.0 <= result["accuracy"] <= 1.0
if dim == "real_jd":
assert result["samples"] == 200 and result["bad_lines"] == 0
assert result["fields"]["required_skills"]["f1"] >= 0.80 # 任务目标
else:
assert result["samples"] == 50
assert result["name_accuracy"] == 1.0 # 任务目标:脱敏后姓氏+*
assert result["phone_accuracy"] == 1.0
+105
View File
@@ -0,0 +1,105 @@
"""SQLite 应用状态库测试:建表幂等 / 留痕追加与查询 / 候选增删查 / 任务表 / 旧文件迁移。"""
from __future__ import annotations
import json
from app.storage import db
REVIEW_REC = {"candidate_id": "cand_A", "action": "accept", "operator": "张三",
"reason": "证据充分", "before": {"status": "pending"},
"after": {"status": "active"}, "time": "2026-08-01 10:00:00"}
NODE_REC = {"kind": "node_update", "node_id": "job_X", "operator": "李四",
"reason": "校正", "before": {"level": None},
"after": {"level": "高级"}, "time": "2026-08-01 11:00:00"}
CAND = {"job_id": "cand_旧候选", "title": "旧候选", "category": "新岗位候选",
"required_skills": ["Python"], "status": "pending",
"source_count": 10, "company_count": 6}
def test_init_schema_idempotent(tmp_path):
"""重复打开/建表无副作用,四张表齐备。"""
p = tmp_path / "state.db"
for _ in range(2):
conn = db.connect(p)
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
assert {"audit_log", "review_candidates", "tasks", "meta"} <= tables
conn.close()
def test_audit_append_and_query(tmp_path):
"""留痕追加后按新→旧返回,结构还原为旧 jsonl 行,支持过滤与 limit。"""
p = tmp_path / "state.db"
db.append_audit(p, REVIEW_REC)
db.append_audit(p, NODE_REC)
recs = db.get_audit(p)
assert [r["operator"] for r in recs] == ["李四", "张三"] # 新→旧
assert recs[1]["candidate_id"] == "cand_A" and recs[1]["action"] == "accept"
assert recs[1]["before"] == {"status": "pending"}
assert recs[0]["kind"] == "node_update" and recs[0]["after"] == {"level": "高级"}
assert db.get_audit(p, node_id="cand_A") == [recs[1]] # 过滤
assert len(db.get_audit(p, limit=1)) == 1 # limit
def test_candidates_crud(tmp_path):
"""候选:save/get/list/delete,save 为按 job_id 的 upsert。"""
p = tmp_path / "state.db"
assert db.list_candidates(p) == []
db.save_candidate(p, CAND)
db.save_candidate(p, {**CAND, "job_id": "cand_B", "title": "另一个"})
db.save_candidate(p, {**CAND, "title": "旧候选-改"}) # upsert 覆盖
assert [c["job_id"] for c in db.list_candidates(p)] == ["cand_旧候选", "cand_B"]
assert db.get_candidate(p, "cand_旧候选")["title"] == "旧候选-改"
assert db.get_candidate(p, "cand_不存在") is None
db.delete_candidate(p, "cand_B")
assert [c["job_id"] for c in db.list_candidates(p)] == ["cand_旧候选"]
def test_tasks_upsert_get_list(tmp_path):
"""任务表:upsert 更新状态与 payload,get/list 可读回。"""
p = tmp_path / "state.db"
db.upsert_task(p, "t1", kind="jd_import", status="pending", payload={"status": "pending"})
db.upsert_task(p, "t1", kind="jd_import", status="done",
payload={"status": "done", "result": {"jobs": 18}})
row = db.get_task(p, "t1")
assert row["status"] == "done" and row["payload"]["result"]["jobs"] == 18
assert db.get_task(p, "不存在") is None
assert [t["task_id"] for t in db.list_tasks(p)] == ["t1"]
def test_overrides_crud(tmp_path):
"""人工覆写层:save/get/list/delete,save 为按 name 的 upsert。"""
p = tmp_path / "state.db"
assert db.list_overrides(p) == []
db.save_override(p, "cand_X", {"kind": "job_accept", "job": {"job_id": "cand_X"}})
db.save_override(p, "job_Y", {"kind": "node_update", "node_id": "job_Y",
"updates": {"level": "高级"}})
db.save_override(p, "cand_X", {"kind": "job_accept",
"job": {"job_id": "cand_X", "title": "改"}}) # upsert 覆盖
assert db.get_override(p, "cand_X")["job"]["title"] == "改"
assert db.get_override(p, "不存在") is None
assert [o["kind"] for o in db.list_overrides(p)] == ["job_accept", "node_update"]
db.delete_override(p, "cand_X")
assert [o["kind"] for o in db.list_overrides(p)] == ["node_update"]
def test_legacy_migration(tmp_path):
"""旧 audit.jsonl / pending.json 一次性导入:表空才迁、原文件保留、重复打开不重复迁。"""
audit = tmp_path / "audit.jsonl"
audit.write_text("\n".join(json.dumps(r, ensure_ascii=False)
for r in (REVIEW_REC, NODE_REC)) + "\n", encoding="utf-8")
pending = tmp_path / "pending.json"
pending.write_text(json.dumps([CAND], ensure_ascii=False), encoding="utf-8")
p = tmp_path / "state.db"
db.connect(p, legacy_audit=audit, legacy_pending=pending).close()
recs = db.get_audit(p)
assert len(recs) == 2 and recs[1]["candidate_id"] == "cand_A"
assert [c["job_id"] for c in db.list_candidates(p)] == ["cand_旧候选"]
assert audit.exists() and pending.exists() # 原文件保留
# 再次打开不重复迁移(meta 标记 + 表非空双保险)
db.connect(p, legacy_audit=audit, legacy_pending=pending).close()
assert len(db.get_audit(p)) == 2 and len(db.list_candidates(p)) == 1
# 迁移后新写入的数据不受影响
db.append_audit(p, REVIEW_REC)
assert len(db.get_audit(p)) == 3
+78
View File
@@ -0,0 +1,78 @@
"""时序图谱测试:时间点切片、演化追加不覆盖、持久化往返。"""
from __future__ import annotations
from app.graph.tkg import TemporalKG, quarter_of, to_window
from app.models import ChangeEvent, GraphObservation, JobDefinition
def _kg() -> TemporalKG:
kg = TemporalKG()
kg.add_job(JobDefinition(job_id="job_T", title="测试工程师"))
return kg
def test_quarter_and_window_conversion():
from datetime import date
assert quarter_of(date(2022, 2, 15)) == "2022Q1"
assert quarter_of(date(2022, 11, 1)) == "2022Q4"
assert to_window("2022-05-01") == "2022Q2"
assert to_window("2022Q3") == "2022Q3"
def test_snapshot_time_slicing():
"""任意时间点切片:只看到该时刻已生效且未退出的技能。"""
kg = _kg()
kg.observe(GraphObservation(job_id="job_T", skill="Java", importance="required",
weight=1.0, window="2020Q1", source_ids=["s1"]))
kg.observe(GraphObservation(job_id="job_T", skill="Spring Cloud", importance="required",
weight=1.0, window="2023Q1", source_ids=["s2"]))
snap_2020 = kg.snapshot("job_T", "2020-06-01")
assert {s["skill"] for s in snap_2020["skills"]} == {"Java"}
snap_2024 = kg.snapshot("job_T", "2024-01-01")
assert {s["skill"] for s in snap_2024["skills"]} == {"Java", "Spring Cloud"}
def test_close_interval_removes_from_snapshot():
kg = _kg()
kg.observe(GraphObservation(job_id="job_T", skill="Struts", importance="preferred",
weight=1.0, window="2019Q1", source_ids=["s1"]))
kg.close_interval("job_T", "Struts", "2022Q1")
assert {s["skill"] for s in kg.snapshot("job_T", "2021-01-01")["skills"]} == {"Struts"}
assert kg.snapshot("job_T", "2023-01-01")["skills"] == []
def test_evolution_appends_not_overwrites():
"""同一技能退出后重新出现:新区间追加,旧区间保留(历史不覆盖)。"""
kg = _kg()
obs = GraphObservation(job_id="job_T", skill="Kafka", importance="required",
weight=1.0, window="2020Q1", source_ids=["s1"])
kg.observe(obs)
kg.close_interval("job_T", "Kafka", "2021Q1")
kg.observe(GraphObservation(job_id="job_T", skill="Kafka", importance="required",
weight=0.8, window="2023Q1", source_ids=["s2"]))
intervals = kg.g["job_T"]["Kafka"]["intervals"]
assert intervals == [["2020Q1", "2021Q1"], ["2023Q1", None]]
assert kg.snapshot("job_T", "2020-06-01")["skills"] # 旧区间仍可切片
assert kg.snapshot("job_T", "2022-01-01")["skills"] == [] # 退出期间为空
assert kg.snapshot("job_T", "2024-01-01")["skills"] # 重现后恢复
# 事件流只追加
kg.add_event(ChangeEvent(event_id="e1", job_id="job_T", skill="Kafka",
change_type="退出", window="2021Q1", description="x"))
kg.add_event(ChangeEvent(event_id="e2", job_id="job_T", skill="Kafka",
change_type="新增", window="2023Q1", description="y"))
assert [e.event_id for e in kg.events_of("job_T")] == ["e1", "e2"]
def test_save_load_roundtrip(tmp_path):
kg = _kg()
kg.observe(GraphObservation(job_id="job_T", skill="Java", importance="required",
weight=1.0, window="2020Q1", source_ids=["s1"]))
kg.add_event(ChangeEvent(event_id="e1", job_id="job_T", skill="Java",
change_type="新增", window="2020Q1", description="d"))
path = tmp_path / "g.json"
kg.save(path)
kg2 = TemporalKG.load(path)
assert kg2.stats()["edges"] == 1
assert kg2.jobs["job_T"].title == "测试工程师"
assert kg2.snapshot("job_T", "2020-06-01")["skills"][0]["skill"] == "Java"
assert kg2.events_of("job_T")[0].event_id == "e1"