"""职称硬指标核验内核(唯一业务内核)。 核验规则(对齐《核心建设内容:各部门"Skill"深度需求规格书》人事处 Skill 001): R1 逐项比对:实有 < 最低要求 → 红(硬伤,附依据条款) R2 边缘识别:达标但余量 < 10% → 黄(建议补充说明或再积累) R3 安全判定:全部已核项 ≥ 要求 × 1.10 → 绿 R4 数据待核:指标缺失 → 灰(不判不达标,提示补材料) R5 综合结论:任一红 → 硬伤(暂缓提交);有黄/灰 → 边缘(建议补充);全绿 → 安全 R6 基础条件:学历/任现职年限与门槛同口径比对 V1 门槛库完备性:指标无对应门槛 → 请人事处补充配置 核验仅针对硬指标形式审查,不构成评审结论。 """ from __future__ import annotations import json import uuid from typing import Any, Optional from db.connection import get_conn, init_db from db.seed_data import KNOWN_RANKS from util.logging_config import get_skill_logger from util.timeutil import now_unix VERDICT_SAFE = "安全" VERDICT_BORDER = "边缘" VERDICT_RED = "硬伤" _COLOR_PASS = "green" _COLOR_BORDER = "yellow" _COLOR_RED = "red" _COLOR_PENDING = "gray" _SAFE_MARGIN = 0.10 _METRIC_ALIASES = { "teacherName": ("teacher_name", "姓名", "教师姓名"), "department": ("department", "学院", "所属学院"), "applyRank": ("apply_rank", "申报级别", "申报职称"), "presidingProvincial": ("presiding_provincial", "主持省部级项目", "省部级项目"), "corePapers": ("core_papers", "核心期刊论文", "核心论文"), "firstAuthorCorePapers": ("first_author_core_papers", "核心期刊一作/通讯论文", "一作/通讯论文"), "teachingHours": ("teaching_hours", "年均教学工作量", "教学工作量"), "currentTitleYears": ("current_title_years", "任现职年限", "现职称年限"), "monographs": ("monographs", "专著/教材", "专著"), "awards": ("awards", "教学/科研获奖", "获奖"), "degree": ("degree", "学历学位", "学历"), } def _log(): try: return get_skill_logger() except RuntimeError: return None 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 _snake(name: str) -> str: return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_") def norm_metrics(raw: dict[str, Any]) -> dict[str, Any]: """归一化申报指标键(兼容 camelCase / snake_case / 中文键,canonical 同时提供两种形式)。""" out: dict[str, Any] = {} for key, value in raw.items(): out.setdefault(str(key), value) for canonical, names in _METRIC_ALIASES.items(): if str(key) in names or _camel(str(key)) == canonical: out.setdefault(canonical, value) out.setdefault(_snake(canonical), value) return out def _to_float(value: Any) -> Optional[float]: if value is None or value == "": return None try: return float(value) except (TypeError, ValueError): return None def load_thresholds(rank: str) -> dict[str, dict[str, Any]]: """读取某级别的门槛(metric_key → 门槛行)。""" conn = get_conn() try: cur = conn.cursor() cur.execute( "SELECT metric_key, metric_name, min_value, unit, basis_doc, clause FROM threshold_rules WHERE rank = ?", (rank,), ) thresholds = {} for metric_key, metric_name, min_value, unit, basis_doc, clause in cur.fetchall(): thresholds[str(metric_key)] = { "metric_name": metric_name, "min_value": float(min_value), "unit": unit or "", "basis_doc": basis_doc or "", "clause": clause or "", } finally: conn.close() return thresholds def verify_metrics(metrics_raw: dict[str, Any], thresholds: dict[str, dict[str, Any]]) -> dict[str, Any]: """对单个申报案例执行矩阵计算(纯计算,不落库)。""" metrics = norm_metrics(metrics_raw) teacher_name = str(metrics.get("teacherName") or "").strip() or "未命名申报人" apply_rank = str(metrics.get("applyRank") or "").strip() matrix: list[dict[str, Any]] = [] has_red = False has_yellow_or_gray = False for metric_key, threshold in thresholds.items(): actual_raw = metrics.get(metric_key) required = float(threshold["min_value"]) unit = threshold["unit"] basis = threshold["basis_doc"] + (threshold["clause"] or "") display_actual = metrics.get(metric_key) actual = _to_float(actual_raw) if actual is None: has_yellow_or_gray = True matrix.append({ "metric": threshold["metric_name"], "metric_key": metric_key, "required": required, "actual": display_actual, "margin": None, "color": _COLOR_PENDING, "basis": basis, "advice": f"数据待核:请补充{threshold['metric_name']}证明材料后重新核验(R4)。", }) continue margin = round((actual - required) / required, 4) if required > 0 else None if actual < required: has_red = True advice = f"硬伤:{threshold['metric_name']}实有 {actual:g} {unit},未达最低要求 {required:g} {unit},须补足后方可提交" color = _COLOR_RED elif margin is not None and margin < _SAFE_MARGIN: has_yellow_or_gray = True advice = f"边缘项:仅超线 {margin * 100:.1f}%,建议补充说明或继续积累(R2)" color = _COLOR_BORDER else: advice = "" color = _COLOR_PASS matrix.append({ "metric": threshold["metric_name"], "metric_key": metric_key, "required": required, "actual": actual, "margin": margin, "color": color, "basis": basis, "advice": f"{advice}(依据:{basis})" if advice else "", }) # V1:申报指标中存在、但该级别门槛库未配置的指标。 # 覆盖判定以"该级别门槛键 + 其全部别名形式"为准——已知别名但本级别未配门槛 # 的指标同样要出 V1 灰行,绝不能当作已核验而漏掉。 identity_keys = {"teacher_name", "teacherName", "department", "apply_rank", "applyRank"} def _alias_canonical(key: str) -> Optional[str]: for canonical, names in _METRIC_ALIASES.items(): if key == canonical or key == _snake(canonical) or key in names: return canonical return None covered_names: set[str] = set() for tk in thresholds: covered_names.add(tk) covered_names.add(_camel(tk)) for canonical, names in _METRIC_ALIASES.items(): if canonical in thresholds or _snake(canonical) in thresholds: covered_names.add(canonical) covered_names.add(_snake(canonical)) covered_names.update(names) seen_v1: set[str] = set() for key in metrics: if key in identity_keys or key in covered_names: continue if metrics.get(key) in (None, ""): continue canonical = _alias_canonical(key) v1_key = _snake(canonical) if canonical else str(key) if v1_key in thresholds or v1_key in seen_v1: continue seen_v1.add(v1_key) has_yellow_or_gray = True display = str(_METRIC_ALIASES[canonical][0]) if canonical else str(key) matrix.append({ "metric": display, "metric_key": v1_key, "required": None, "actual": metrics.get(key), "margin": None, "color": _COLOR_PENDING, "basis": "", "advice": "门槛库未配置该指标的最低要求,请人事处补充(V1)。", }) if has_red: verdict = VERDICT_RED summary = "存在不达门槛的硬伤项,暂缓提交;请按矩阵补足材料后重新核验。" elif has_yellow_or_gray or not thresholds: verdict = VERDICT_BORDER if not thresholds: summary = f"门槛库未配置「{apply_rank}」级别的门槛,本次无法完成硬指标核验,请人事处补充配置后重新核验(V1)。" else: summary = "无硬伤,但存在边缘项或待核数据,建议补充说明后提交。" else: verdict = VERDICT_SAFE summary = "全部硬指标超标准 10% 以上,形式审查通过。" return { "teacher_name": teacher_name, "department": str(metrics.get("department") or ""), "apply_rank": apply_rank, "verdict": verdict, "summary": summary, "matrix": matrix, } def collect_input_cases(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") ) cases: 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("cases") or [payload] if not isinstance(payload, list): raise ValueError("内容不是申报案例对象数组") cases.extend(item for item in payload if isinstance(item, dict)) except (OSError, ValueError) as exc: skipped_files.append({"file": name, "reason": str(exc)}) return cases, skipped_files def run_verify(cases: list[dict[str, Any]], *, batch_no: str = "") -> dict[str, Any]: """核验内核:逐案例矩阵计算 → 写库 → 生成《核验报告》。""" from service.report_service import write_report_for_kernel, build_batch_brief log = _log() init_db() if not cases: raise ValueError("没有可核验的申报案例") if len(cases) > 200: raise ValueError(f"单批核验案例数过多({len(cases)}),上限 200 个") batch_no = batch_no or now_unix().to_bytes(5, "big").hex() + uuid.uuid4().hex[:6] if log: log.info("verify_kernel_start batch_no=%s count=%s stage=kernel", batch_no, len(cases)) results = [] conn = get_conn() try: cur = conn.cursor() for case in cases: if not isinstance(case, dict): raise ValueError("申报案例必须是 JSON 对象") rank = str(case.get("applyRank") or case.get("apply_rank") or "").strip() if rank not in KNOWN_RANKS: raise ValueError( f"申报级别只接受 {'、'.join(KNOWN_RANKS)}:{rank or '(未提供)'}(RANK_INVALID)" ) result = verify_metrics(case, load_thresholds(rank)) cur.execute( """ INSERT INTO verify_cases (batch_no, teacher_name, department, apply_rank, metrics_json, verdict, matrix_json) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( batch_no, result["teacher_name"], result["department"], result["apply_rank"], json.dumps(case, ensure_ascii=False), result["verdict"], json.dumps(result["matrix"], ensure_ascii=False), ), ) case_id = int(cur.lastrowid) result["case_id"] = case_id result["batch_no"] = batch_no report_file = write_report_for_kernel(result=result, batch_no=batch_no) cur.execute("UPDATE verify_cases SET report_file = ? WHERE id = ?", (report_file, case_id)) result["report_file"] = report_file results.append(result) conn.commit() finally: conn.close() verdict_counts: dict[str, int] = {} for result in results: verdict_counts[result["verdict"]] = verdict_counts.get(result["verdict"], 0) + 1 brief_file = build_batch_brief(batch_no=batch_no, results=results) payload = { "ok": True, "batch_no": batch_no, "count": len(results), "verdict_counts": verdict_counts, "brief_file": brief_file, "disclaimer": "核验仅针对硬指标形式审查,不构成评审结论;门槛以人事处最新文件为准。", "reports": results, } if log: log.info( "verify_kernel_done batch_no=%s count=%s red=%s yellow=%s green=%s stage=kernel", batch_no, len(results), verdict_counts.get(VERDICT_RED, 0), verdict_counts.get(VERDICT_BORDER, 0), verdict_counts.get(VERDICT_SAFE, 0), ) return payload