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,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 == []
|
||||
Reference in New Issue
Block a user