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

View File

@@ -0,0 +1,349 @@
"""赛道匹配内核(唯一业务内核)。
匹配算法(对齐《核心建设内容:各部门"Skill"深度需求规格书》科研处 Skill 001):
H1/H2 硬门槛过滤(职称 / SCI 一区)→ 剔除并说明原因
S1 标签覆盖率 = 教师标签 ∩ 赛道标签 / 赛道标签数
S2 优先方向命中(每命中 +0.15,封顶 0.3)
S3 综合得分 = 0.7×覆盖率 + 优先命中(百分数展示)
T1 分层:≥80 强烈推荐;50~80 可尝试;<50 不建议
T2 Top 3 排序输出
E1 画像完整性:无研究标签 → 不匹配,提示补录
全部得分确定性计算、理由可追溯到具体标签;推荐仅供参考,不构成申报决定。
"""
from __future__ import annotations
import json
import uuid
from typing import Any, Optional
from db.connection import get_conn, init_db
from util.logging_config import get_skill_logger
from util.timeutil import now_unix
TIER_STRONG = "强烈推荐"
TIER_TRY = "可尝试"
TIER_NOT = "不建议"
_TIER_RANK = {TIER_NOT: 0, TIER_TRY: 1, TIER_STRONG: 2}
# 职称档位:数值越大越高职称;门槛 min_title 使用"X及以上"档位
_TITLE_RANK = {
"助教": 0,
"讲师": 1,
"助理研究员": 1,
"博士后": 1,
"副高及以上": 2,
"副教授": 2,
"副研究员": 2,
"高级实验师": 2,
"正高及以上": 3,
"教授": 3,
"研究员": 3,
}
_STRONG_THRESHOLD = 80
_TRY_THRESHOLD = 50
def _log():
try:
return get_skill_logger()
except RuntimeError:
return None
def _split_tags(raw: Any) -> list[str]:
"""把逗号/分号/顿号分隔的标签文本拆成去空格列表。"""
if not raw:
return []
import re
return [t for t in (s.strip() for s in re.split(r"[,,;;、\n]", str(raw))) if t]
def _title_rank(title: str) -> Optional[int]:
if not title:
return None
return _TITLE_RANK.get(str(title).strip())
def _min_title_rank(min_title: str) -> Optional[int]:
text = str(min_title or "").strip()
if not text:
return None
if text in _TITLE_RANK:
return _TITLE_RANK[text]
if text.endswith("及以上"):
return _TITLE_RANK.get(text[:-3])
return None
def _norm_teacher(raw: dict[str, Any]) -> dict[str, Any]:
"""归一化教师画像键(兼容 camelCase / snake_case / 常见中文键)。"""
aliases = {
"teacherName": ("teacher_name", "姓名", "教师姓名"),
"department": ("department", "学院", "所属学院"),
"title": ("title", "职称"),
"researchTags": ("research_tags", "研究标签", "标签"),
"recentPapers": ("recent_papers", "近3年论文", "论文"),
"projectHistory": ("project_history", "项目经历", "项目"),
"sciQ1Count": ("sci_q1_count", "sci一区", "sci一区篇数"),
"corePaperCount": ("core_paper_count", "核心论文", "核心论文篇数"),
}
out: dict[str, Any] = {}
for key, value in raw.items():
out.setdefault(str(key), value)
for canonical, names in aliases.items():
if str(key) in names or _camel(str(key)) == canonical:
out.setdefault(canonical, value)
return out
def _norm_track(raw: dict[str, Any]) -> dict[str, Any]:
aliases = {
"year": ("year", "年度", "指南年度"),
"agency": ("agency", "学部", "归口"),
"programName": ("program_name", "项目类型"),
"trackName": ("track_name", "赛道名称", "赛道"),
"trackTags": ("track_tags", "需求标签"),
"priorityKeywords": ("priority_keywords", "优先方向", "优先资助方向"),
"minTitle": ("min_title", "职称门槛"),
"minSciQ1": ("min_sci_q1", "sci一区门槛"),
"quota": ("quota", "拟资助数"),
"sourceDoc": ("source_doc", "指南来源"),
}
out: dict[str, Any] = {}
for key, value in raw.items():
out.setdefault(str(key), value)
for canonical, names in aliases.items():
if str(key) in names or _camel(str(key)) == canonical:
out.setdefault(canonical, value)
return out
def _camel(name: str) -> str:
parts = str(name).split("_")
if len(parts) <= 1:
return str(name)
head, *rest = parts
return head + "".join(p[:1].upper() + p[1:] for p in rest if p)
def _to_int(value: Any) -> Optional[int]:
if value is None or value == "":
return None
try:
return int(float(value))
except (TypeError, ValueError):
return None
def match_teacher(teacher: dict[str, Any], tracks: list[dict[str, Any]], season: str) -> dict[str, Any]:
"""对单个教师执行硬门槛过滤 + 标签匹配,返回分层结果(不落库)。"""
teacher = _norm_teacher(teacher)
tracks = [_norm_track(t) for t in tracks]
teacher_name = str(teacher.get("teacherName") or "").strip() or "未命名教师"
teacher_tags = _split_tags(teacher.get("researchTags"))
papers_text = str(teacher.get("recentPapers") or "")
projects_text = str(teacher.get("projectHistory") or "")
sci_q1 = _to_int(teacher.get("sciQ1Count")) or 0
title_rank = _title_rank(str(teacher.get("title") or ""))
if not teacher_tags and not papers_text:
raise ValueError(f"教师 {teacher_name} 画像不完整:请至少填写研究标签或论文标题(E1)")
eligible: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
for track in tracks:
track_name = str(track.get("trackName") or "").strip()
program = str(track.get("programName") or "").strip()
label = f"{program}·{track_name}" if program else track_name
min_title = str(track.get("minTitle") or "").strip()
required_rank = _min_title_rank(min_title)
if required_rank is not None and (title_rank is None or title_rank < required_rank):
excluded.append({"track": label, "reason": f"职称门槛:要求{min_title},现为{teacher.get('title') or '未填写'}(H1)"})
continue
min_sci = _to_int(track.get("minSciQ1")) or 0
if min_sci > 0 and sci_q1 < min_sci:
excluded.append({"track": label, "reason": f"SCI一区门槛:要求 {min_sci} 篇,现有 {sci_q1} 篇(H2)"})
continue
track_tags = _split_tags(track.get("trackTags"))
if not track_tags:
excluded.append({"track": label, "reason": "赛道未配置需求标签,无法匹配(数据待补)"})
continue
matched_tags = [t for t in track_tags if t in teacher_tags]
coverage = round(len(matched_tags) / len(track_tags), 4)
priority_hits = []
haystack = ",".join(teacher_tags) + ";" + papers_text + ";" + projects_text
for keyword in _split_tags(track.get("priorityKeywords")):
if keyword and keyword in haystack:
priority_hits.append(keyword)
score = round(0.7 * coverage + min(len(priority_hits) * 0.15, 0.3), 4)
match_pct = int(round(score * 100))
reasons = []
if matched_tags:
reasons.append(f"命中需求标签:{'、'.join(matched_tags)}(覆盖 {len(matched_tags)}/{len(track_tags)})")
if priority_hits:
reasons.append(f"优先资助方向命中:{'、'.join(priority_hits)}")
if min_sci > 0:
reasons.append(f"SCI一区余量:现有 {sci_q1} 篇 / 要求 {min_sci} 篇")
if not reasons:
reasons.append("与赛道方向存在部分相关性")
if match_pct >= _STRONG_THRESHOLD:
tier = TIER_STRONG
elif match_pct >= _TRY_THRESHOLD:
tier = TIER_TRY
else:
tier = TIER_NOT
if tier == TIER_TRY:
reasons.append("建议补充与赛道直接相关的成果说明后再申报")
eligible.append(
{
"track": label,
"track_name": track_name,
"program_name": program,
"agency": str(track.get("agency") or ""),
"match_pct": match_pct,
"tier": tier,
"matched_tags": matched_tags,
"coverage": coverage,
"priority_hits": priority_hits,
"reasons": reasons,
}
)
eligible.sort(key=lambda x: (-x["match_pct"], x["track"]))
tiers: dict[str, list[dict[str, Any]]] = {TIER_STRONG: [], TIER_TRY: [], TIER_NOT: []}
for entry in eligible:
tiers[entry["tier"]].append(entry)
top3 = eligible[:3]
return {
"teacher_name": teacher_name,
"department": str(teacher.get("department") or ""),
"title": str(teacher.get("title") or ""),
"season": str(season),
"eligible_count": len(eligible),
"excluded": excluded,
"top3": top3,
"tiers": {k: v for k, v in tiers.items() if v},
}
def _write_report(result: dict[str, Any], batch_no: str) -> str:
from service.report_service import write_report_for_kernel
return write_report_for_kernel(result=result, batch_no=batch_no)
def run_match(teachers_raw: list[dict[str, Any]], tracks: list[dict[str, Any]], season: str, *, batch_no: str = "") -> dict[str, Any]:
"""匹配内核:逐教师匹配 → 写库 → 生成建议书(多人时另出汇总页)。"""
from service.report_service import build_batch_brief
log = _log()
init_db()
if not teachers_raw:
raise ValueError("没有可匹配的教师画像")
if not tracks:
raise ValueError("指南赛道库为空:请先录入当年指南赛道(add-track / import-tracks)")
batch_no = batch_no or now_unix().to_bytes(5, "big").hex() + uuid.uuid4().hex[:6]
if log:
log.info("match_kernel_start batch_no=%s teachers=%s tracks=%s season=%s stage=kernel", batch_no, len(teachers_raw), len(tracks), season)
results = []
conn = get_conn()
try:
cur = conn.cursor()
for teacher_raw in teachers_raw:
result = match_teacher(teacher_raw, tracks, season)
cur.execute(
"""
INSERT INTO match_reports
(batch_no, teacher_name, department, season, top_json, tiers_json, eligible_count, excluded_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
batch_no,
result["teacher_name"],
result["department"],
result["season"],
json.dumps(result["top3"], ensure_ascii=False),
json.dumps(result["tiers"], ensure_ascii=False),
result["eligible_count"],
json.dumps(result["excluded"], ensure_ascii=False),
),
)
report_id = int(cur.lastrowid)
result["report_id"] = report_id
result["batch_no"] = batch_no
report_file = _write_report(result, batch_no)
cur.execute("UPDATE match_reports SET report_file = ? WHERE id = ?", (report_file, report_id))
result["report_file"] = report_file
results.append(result)
conn.commit()
finally:
conn.close()
brief_file = build_batch_brief(batch_no=batch_no, results=results, season=season)
tier_counts: dict[str, int] = {}
for result in results:
for tier, entries in result["tiers"].items():
tier_counts[tier] = tier_counts.get(tier, 0) + len(entries)
payload = {
"ok": True,
"batch_no": batch_no,
"season": season,
"count": len(results),
"tier_counts": tier_counts,
"brief_file": brief_file,
"disclaimer": "推荐仅供参考,申报决策请结合个人规划与学院统筹。",
"reports": results,
}
if log:
log.info("match_kernel_done batch_no=%s count=%s stage=kernel", batch_no, len(results))
return payload
def load_tracks(year: Optional[int] = None) -> list[dict[str, Any]]:
"""读取赛道库(缺省全部年度;可按年度筛选)。"""
conn = get_conn()
try:
cur = conn.cursor()
if year:
cur.execute("SELECT * FROM guide_tracks WHERE year = ? ORDER BY id", (year,))
else:
cur.execute("SELECT * FROM guide_tracks ORDER BY year DESC, id")
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
finally:
conn.close()
return rows
def load_teachers(teacher_name: str = "") -> list[dict[str, Any]]:
"""读取教师画像(可按姓名精确筛选)。"""
conn = get_conn()
try:
cur = conn.cursor()
if teacher_name:
cur.execute("SELECT * FROM teacher_profiles WHERE teacher_name = ?", (teacher_name,))
else:
cur.execute("SELECT * FROM teacher_profiles ORDER BY id")
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
finally:
conn.close()
return rows