Files

346 lines
13 KiB
Python
Raw Permalink 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):
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