"""抽取层测试: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 == []