feat: 申报赛道推荐助手首版 v1.0.1(match-guide-track-default)
All checks were successful
技能自动化发布 / release (push) Successful in 6s

This commit is contained in:
chenfuqiang
2026-09-13 15:50:33 +08:00
commit 7c1f40b0e6
205 changed files with 29720 additions and 0 deletions

171
tests/test_match_rules.py Normal file
View File

@@ -0,0 +1,171 @@
# -*- 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)
if __name__ == "__main__":
unittest.main()