Files
match-guide-track-default/scripts/service/match_service.py
chenfuqiang 4a8613b27e fix: 批量匹配跳过画像待补录教师 + render-html 按行重建 + 金标准 fixtures 业务化(P0+P1)
- run_match 新增 skip_incomplete:批量缺省跳过画像不完整教师并记录 skipped,指定单教师仍 PROFILE_INCOMPLETE;汇总页出"画像待补录"行
- render-html 补 --report-id 行上下文(inputMapping $row.$pk),CLI 容错解析回退最近 10 份
- report_service 日志改容错 _log(),修复未 setup 时单跑测试崩溃的顺序依赖
- fixtures 替换为教师画像/赛道/期望结论金标准样例,新增 sample_tracks.json
- 新增 4 个批量跳过用例(314 项,除 3 个已知 jsonschema 环境失败外全绿)
- tests/README 增补本机无共享 runtime 的测试方式;TUTORIAL/ACTIONS.md 同步表述
2026-09-24 18:01:41 +08:00

378 lines
14 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,
"讲师": 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