Files
match-guide-track-default/scripts/service/match_service.py

419 lines
16 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.
"""赛道匹配内核(唯一业务内核)。
匹配算法(对齐《核心建设内容:各部门"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