Files
match-guide-track-default/tests/test_match_rules.py
chenfuqiang 4a8613b27e fix: 批量匹配跳过画像待补录教师 + render-html 按行重建 + 金标准 fixtures 业务化(P0+P1)
- run_match 新增 skip_incomplete:批量缺省跳过画像不完整教师并记录 skipped,指定单教师仍 PROFILE_INCOMPLETE;汇总页出"画像待补录"行
- render-html 补 --report-id 行上下文(inputMapping $row.$pk),CLI 容错解析回退最近 10 份
- report_service 日志改容错 _log(),修复未 setup 时单跑测试崩溃的顺序依赖
- fixtures 替换为教师画像/赛道/期望结论金标准样例,新增 sample_tracks.json
- 新增 4 个批量跳过用例(314 项,除 3 个已知 jsonschema 环境失败外全绿)
- tests/README 增补本机无共享 runtime 的测试方式;TUTORIAL/ACTIONS.md 同步表述
2026-09-24 18:01:41 +08:00

215 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""赛道匹配内核金标准用例(离线、确定性)。"""
from __future__ import annotations
import unittest
from _support import IsolatedDataRoot
def _teacher(**overrides) -> dict:
base = {
"teacherName": "张教授",
"department": "化材学院",
"title": "教授",
"researchTags": "钙钛矿,光伏,半导体,界面调控,稳定性",
"recentPapers": "钙钛矿电池界面钝化研究",
"projectHistory": "主持省自然科学基金",
"sciQ1Count": 3,
"corePaperCount": 8,
}
base.update(overrides)
return base
def _tracks() -> list[dict]:
return [
{"year": 2027, "programName": "面上项目", "trackName": "光伏材料", "trackTags": "钙钛矿,光伏,半导体", "priorityKeywords": "界面调控,稳定性", "minTitle": "副高及以上"},
{"year": 2027, "programName": "青年项目", "trackName": "柔性电子", "trackTags": "二维材料,柔性电子,钙钛矿"},
{"year": 2027, "programName": "青年项目", "trackName": "农村经济", "trackTags": "乡村振兴,农村经济"},
{"year": 2027, "programName": "重点项目", "trackName": "量子计算", "trackTags": "量子计算,量子信息", "minTitle": "正高及以上", "minSciQ1": 5},
]
class MatchRuleTestBase(unittest.TestCase):
def setUp(self) -> None:
self._root = IsolatedDataRoot()
self._root.__enter__()
from db.connection import init_db
init_db()
def tearDown(self) -> None:
self._root.__exit__(None, None, None)
def _match(self, teacher=None, tracks=None):
from service.match_service import match_teacher
return match_teacher(teacher or _teacher(), tracks if tracks is not None else _tracks(), "2027")
class TestHardThresholds(MatchRuleTestBase):
def test_h1_title_threshold_excludes(self) -> None:
result = self._match(_teacher(title="讲师"))
excluded_tracks = {e["track"] for e in result["excluded"]}
self.assertIn("重点项目·量子计算", excluded_tracks)
self.assertIn("H1", result["excluded"][0]["reason"])
def test_h1_min_title_none_no_filter(self) -> None:
result = self._match(_teacher(title="讲师"))
# 无门槛赛道不受影响
self.assertGreater(result["eligible_count"], 0)
def test_h2_sci_q1_threshold_excludes(self) -> None:
result = self._match(_teacher(title="研究员", sciQ1Count=2))
excluded_tracks = {e["track"] for e in result["excluded"]}
self.assertIn("重点项目·量子计算", excluded_tracks)
self.assertIn("H2", result["excluded"][0]["reason"])
def test_e1_incomplete_profile_raises(self) -> None:
with self.assertRaises(ValueError):
self._match(_teacher(researchTags="", recentPapers=""))
class TestScoring(MatchRuleTestBase):
def test_s3_full_coverage_with_priority_hits(self) -> None:
result = self._match()
top = result["top3"][0]
# 光伏材料:coverage 3/3=1.0 → 0.7 + priority 2 hits 0.3 = 1.0
self.assertEqual(top["track"], "面上项目·光伏材料")
self.assertEqual(top["match_pct"], 100)
self.assertEqual(top["tier"], "强烈推荐")
def test_t1_try_band(self) -> None:
result = self._match(_teacher(researchTags="钙钛矿,光伏", recentPapers=""))
entry = next(e for e in result["top3"] if e["track"] == "面上项目·光伏材料")
# coverage 2/3 → 0.7*0.667=0.4667 → 47% 不建议? + 无 priority → 47%;二维材料 coverage 1/3=0.333→23%
self.assertEqual(entry["match_pct"], 47)
self.assertEqual(entry["tier"], "不建议")
def test_t1_strong_boundary(self) -> None:
# 恰好 80% 属强烈推荐
result = self._match(_teacher(researchTags="钙钛矿,光伏,半导体", recentPapers=""))
entry = next(e for e in result["top3"] if e["track"] == "面上项目·光伏材料")
# coverage 1.0 → 0.7 → 70% 可尝试
self.assertEqual(entry["tier"], "可尝试")
self.assertEqual(entry["match_pct"], 70)
def test_t2_top3_truncation_and_order(self) -> None:
result = self._match()
self.assertLessEqual(len(result["top3"]), 3)
pcts = [e["match_pct"] for e in result["top3"]]
self.assertEqual(pcts, sorted(pcts, reverse=True))
def test_reasons_traceable(self) -> None:
result = self._match()
top = result["top3"][0]
self.assertTrue(any("命中需求标签" in r for r in top["reasons"]))
self.assertTrue(any("优先资助方向" in r for r in top["reasons"]))
class TestKernelAndPersistence(MatchRuleTestBase):
def _run(self, teachers=None):
from service.match_service import run_match
return run_match(teachers or [_teacher()], _tracks(), "2027")
def test_run_match_writes_reports_and_brief(self) -> None:
result = self._run([_teacher(), _teacher(teacherName="李教授", researchTags="乡村振兴,农村金融", title="教授")])
self.assertEqual(result["count"], 2)
self.assertTrue(result["brief_file"].endswith(".html"))
for report in result["reports"]:
self.assertTrue(report["report_file"].endswith(".html"))
with open(report["report_file"], encoding="utf-8") as f:
html_text = f.read()
self.assertIn("申报策略建议书", html_text)
self.assertIn("仅供参考", html_text)
def test_empty_tracks_raises(self) -> None:
from service.match_service import run_match
with self.assertRaises(ValueError):
run_match([_teacher()], [], "2027")
def test_no_teachers_raises(self) -> None:
from service.match_service import run_match
with self.assertRaises(ValueError):
run_match([], _tracks(), "2027")
def test_upsert_teacher_and_track(self) -> None:
from service.profile_service import upsert_teacher, upsert_track
payload = upsert_teacher({"teacher_name": "张教授", "title": "教授"})
self.assertEqual(payload["action"], "created")
payload = upsert_teacher({"teacher_name": "张教授", "sci_q1_count": 5})
self.assertEqual(payload["action"], "updated")
with self.assertRaises(ValueError):
upsert_teacher({"teacher_name": ""})
payload = upsert_track({"year": 2027, "track_name": "光伏材料", "track_tags": "钙钛矿,光伏,半导体"})
self.assertEqual(payload["action"], "created")
with self.assertRaises(ValueError):
upsert_track({"year": 2027, "track_name": "光伏材料", "min_title": "院士及以上"})
def test_stats(self) -> None:
from service.profile_service import stats, upsert_track
for track in _tracks():
upsert_track({
"year": track["year"],
"track_name": track["trackName"],
"program_name": track.get("programName", ""),
"track_tags": track.get("trackTags", ""),
})
self._run()
payload = stats()
self.assertEqual(payload["report_total"], 1)
self.assertEqual(payload["tracks_by_year"].get("2027"), 4)
class TestBatchSkipIncomplete(MatchRuleTestBase):
"""批量缺省跳过画像不完整教师(E1),指定单教师保持严格失败。"""
def test_batch_skips_incomplete_teacher(self) -> None:
from service.match_service import run_match
incomplete = _teacher(teacherName="王讲师", researchTags="", recentPapers="")
payload = run_match([_teacher(), incomplete], _tracks(), "2027")
self.assertEqual(payload["ok"], True)
self.assertEqual(payload["count"], 1)
self.assertEqual(len(payload["skipped"]), 1)
skipped = payload["skipped"][0]
self.assertEqual(skipped["teacher_name"], "王讲师")
self.assertIn("E1", skipped["reason"])
# 正常教师仍有报告
self.assertEqual(len(payload["reports"]), 1)
self.assertEqual(payload["reports"][0]["teacher_name"], "张教授")
def test_batch_brief_marks_skipped_teacher(self) -> None:
from service.match_service import run_match
incomplete = _teacher(teacherName="王讲师", researchTags="", recentPapers="")
payload = run_match([_teacher(), incomplete], _tracks(), "2027")
with open(payload["brief_file"], encoding="utf-8") as f:
html_text = f.read()
self.assertIn("画像待补录", html_text)
self.assertIn("王讲师", html_text)
def test_all_skipped_still_returns_success_payload(self) -> None:
from service.match_service import run_match
payload = run_match([_teacher(researchTags="", recentPapers="")], _tracks(), "2027")
self.assertEqual(payload["ok"], True)
self.assertEqual(payload["count"], 0)
self.assertEqual(len(payload["skipped"]), 1)
def test_strict_mode_raises_for_incomplete(self) -> None:
from service.match_service import run_match
with self.assertRaises(ValueError):
run_match([_teacher(researchTags="", recentPapers="")], _tracks(), "2027", skip_incomplete=False)
if __name__ == "__main__":
unittest.main()