CareerNexus 职联:岗位能力图谱构建与动态演化分析平台(挑战杯 XH-202621)
- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == "一次性技能"]
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user