feat: 职称评审核验助手首版 v1.0.1(verify-promotion-metric-default)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
288
scripts/service/verify_service.py
Normal file
288
scripts/service/verify_service.py
Normal 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
|
||||
Reference in New Issue
Block a user