Files
verify-promotion-metric-def…/scripts/service/verify_service.py
chenfuqiang 04026d0372
All checks were successful
技能自动化发布 / release (push) Successful in 7s
feat: 职称评审核验助手首版 v1.0.1(verify-promotion-metric-default)
2026-09-13 16:05:20 +08:00

289 lines
11 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):
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