419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""赛道匹配内核(唯一业务内核)。
|
||
|
||
匹配算法(对齐《核心建设内容:各部门"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,
|
||
"初级": 0,
|
||
"讲师": 1,
|
||
"中级": 1,
|
||
"助理研究员": 1,
|
||
"助理教授": 1,
|
||
"工程师": 1,
|
||
"博士后": 1,
|
||
"副高及以上": 2,
|
||
"副教授": 2,
|
||
"副研究员": 2,
|
||
"高级实验师": 2,
|
||
"高级工程师": 2,
|
||
"正高及以上": 3,
|
||
"教授": 3,
|
||
"研究员": 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 = "",
|
||
skip_incomplete: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""匹配内核:逐教师匹配 → 写库 → 生成建议书(多人时另出汇总页)。
|
||
|
||
skip_incomplete=True(批量缺省):画像不完整(E1)的教师被跳过并记入
|
||
``skipped``,不中断批次;skip_incomplete=False(指定单教师):直接抛
|
||
ValueError,由 CLI 映射 PROFILE_INCOMPLETE。
|
||
"""
|
||
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 = []
|
||
skipped: list[dict[str, str]] = []
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
for teacher_raw in teachers_raw:
|
||
try:
|
||
result = match_teacher(teacher_raw, tracks, season)
|
||
except ValueError as exc:
|
||
if not skip_incomplete:
|
||
raise
|
||
raw_name = str((teacher_raw or {}).get("teacherName") or (teacher_raw or {}).get("teacher_name") or "").strip() or "未命名教师"
|
||
skipped.append({"teacher_name": raw_name, "reason": str(exc)})
|
||
if log:
|
||
log.warning("match_teacher_skipped teacher=%s reason=%s stage=kernel", raw_name, exc)
|
||
continue
|
||
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, skipped=skipped)
|
||
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),
|
||
"skipped": skipped,
|
||
"tier_counts": tier_counts,
|
||
"brief_file": brief_file,
|
||
"disclaimer": "推荐仅供参考,申报决策请结合个人规划与学院统筹。",
|
||
"reports": results,
|
||
}
|
||
if log:
|
||
log.info(
|
||
"match_kernel_done batch_no=%s count=%s skipped=%s stage=kernel",
|
||
batch_no,
|
||
len(results),
|
||
len(skipped),
|
||
)
|
||
return payload
|
||
|
||
|
||
def load_tracks(year: Optional[int] = None) -> list[dict[str, Any]]:
|
||
"""读取赛道库(缺省取赛道库最新年度,与 actions.json 契约一致;显式传 year 按年度筛选)。"""
|
||
conn = get_conn()
|
||
try:
|
||
cur = conn.cursor()
|
||
if year is None:
|
||
cur.execute("SELECT MAX(year) FROM guide_tracks")
|
||
row = cur.fetchone()
|
||
if not row or row[0] is None:
|
||
return []
|
||
year = int(row[0])
|
||
cur.execute("SELECT * FROM guide_tracks WHERE year = ? ORDER BY id", (year,))
|
||
columns = [d[0] for d in cur.description]
|
||
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
|
||
finally:
|
||
conn.close()
|
||
return rows
|
||
|
||
|
||
def collect_input_teachers(input_dir: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||
"""读取 input/ 目录下的教师画像 JSON;坏文件跳过并记录原因,不中断整批。
|
||
|
||
返回 (教师列表, 跳过文件列表);跳过文件形如 {"file": 名称, "reason": 原因}。
|
||
"""
|
||
import os
|
||
|
||
files = sorted(
|
||
os.path.join(input_dir, name)
|
||
for name in os.listdir(input_dir)
|
||
if name.lower().endswith(".json")
|
||
)
|
||
teachers: list[dict[str, Any]] = []
|
||
skipped_files: list[dict[str, str]] = []
|
||
for path in files:
|
||
name = os.path.basename(path)
|
||
try:
|
||
with open(path, encoding="utf-8") as f:
|
||
payload = json.load(f)
|
||
if isinstance(payload, dict):
|
||
payload = payload.get("teachers") or [payload]
|
||
if not isinstance(payload, list):
|
||
raise ValueError("内容不是教师对象数组")
|
||
teachers.extend(item for item in payload if isinstance(item, dict))
|
||
except (OSError, ValueError) as exc:
|
||
skipped_files.append({"file": name, "reason": str(exc)})
|
||
return teachers, skipped_files
|
||
|
||
|
||
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
|