"""赛道匹配内核(唯一业务内核)。 匹配算法(对齐《核心建设内容:各部门"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 = "", 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]]: """读取赛道库(缺省全部年度;可按年度筛选)。""" 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