feat: 职称评审核验助手首版 v1.0.1(verify-promotion-metric-default)
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
chenfuqiang
2026-09-13 16:05:20 +08:00
commit 04026d0372
206 changed files with 29350 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
"""职称硬指标核验内核(唯一业务内核)。
核验规则(对齐《核心建设内容:各部门"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 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", "核心期刊论文", "核心论文"),
"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:申报指标中存在但门槛库未配置的键(排除标识字段与其别名形式)
identity_keys = {"teacher_name", "teacherName", "department", "apply_rank", "applyRank"}
known_keys = set(thresholds.keys()) | set(_METRIC_ALIASES.keys())
unconfigured = []
for key in metrics:
if key in identity_keys or key in known_keys:
continue
if _snake(key) in known_keys or _camel(key) in known_keys:
continue
if metrics.get(key) in (None, ""):
continue
if any(key in names for names in _METRIC_ALIASES.values()):
continue
unconfigured.append(str(key))
for key in unconfigured:
has_yellow_or_gray = True
matrix.append({
"metric": str(_METRIC_ALIASES.get(key, (key,))[0]) if key in _METRIC_ALIASES else key,
"metric_key": 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:
verdict = VERDICT_BORDER
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 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 对象")
result = verify_metrics(case, load_thresholds(str(case.get("applyRank") or case.get("apply_rank") or "").strip()))
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