feat: 申报赛道推荐助手首版 v1.0.1(match-guide-track-default)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
All checks were successful
技能自动化发布 / release (push) Successful in 6s
This commit is contained in:
349
scripts/service/match_service.py
Normal file
349
scripts/service/match_service.py
Normal 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
|
||||
Reference in New Issue
Block a user