fix: RANK_INVALID 落地 + 空门槛库不判安全(改判边缘+V1)+ 批量坏文件跳过 + 教授补一作/通讯独立门槛

This commit is contained in:
chenfuqiang
2026-09-27 23:00:21 +08:00
parent 04026d0372
commit b859a89222
8 changed files with 191 additions and 43 deletions

View File

@@ -16,20 +16,24 @@ _SEED_THRESHOLDS: tuple[tuple[str, str, str, float, str, str, str], ...] = (
("副教授", "current_title_years", "任现职年限", 5.0, "年", _BASIS, "第X条"),
("教授", "presiding_provincial", "主持省部级项目", 2.0, "项", _BASIS, "第X条"),
("教授", "core_papers", "核心期刊论文", 6.0, "篇", _BASIS, "第X条"),
("教授", "monographs", "专著/教材", 1.0, "部", _BASIS, "第X条"),
("教授", "first_author_core_papers", "核心期刊一作/通讯论文", 3.0, "篇", _BASIS, "第X条(其中一作/通讯)"),
("教授", "monographs", "专著/教材(或等效获奖,获奖由人事处认定)", 1.0, "部", _BASIS, "第X条"),
("教授", "current_title_years", "任现职年限", 5.0, "年", _BASIS, "第X条"),
)
_KNOWN_METRIC_KEYS = (
"presiding_provincial",
"core_papers",
"first_author_core_papers",
"teaching_hours",
"current_title_years",
"monographs",
"awards",
"degree",
)
_KNOWN_RANKS = ("副教授", "教授")
KNOWN_RANKS = ("副教授", "教授")
# 兼容旧名
_KNOWN_RANKS = KNOWN_RANKS
def seed_thresholds(cursor: sqlite3.Cursor) -> int:

View File

@@ -22,7 +22,6 @@ def _log():
_TEMPLATE_REL = "assets/html/verify-report.html"
_VERDICT_BADGE = {"安全": "tier-strong", "边缘": "tier-try", "硬伤": "tier-strong"}
_COLOR_BADGE = {"green": "tier-strong", "yellow": "tier-try", "red": "tag-red", "gray": "tier-not"}
_COLOR_MARK = {"green": "🟢 达标", "yellow": "🟡 边缘", "red": "🔴 硬伤", "gray": "⚪ 待核"}
@@ -132,7 +131,8 @@ def build_report_for_case_id(case_id: int) -> str:
result["matrix"] = []
html_text = build_verify_html(result)
path = write_html_page(f"report-{case_id}.html", html_text)
log.info("report_render_done case_id=%s path=%s stage=render", case_id, path)
if log:
log.info("report_render_done case_id=%s path=%s stage=render", case_id, path)
return path

View File

@@ -360,6 +360,7 @@ def cmd_verify_metric(
) -> int:
"""职称硬指标核验(主命令,同步)。"""
log = _get_task_logger()
from db.seed_data import KNOWN_RANKS
from service.verify_service import run_verify
with job_context(skill=SKILL_SLUG):
@@ -376,12 +377,21 @@ def cmd_verify_metric(
metrics = _parse_json_arg(metrics_json, "--metrics-json")
if not metrics:
raise ValueError("申报指标 JSON 为空")
case = metrics[0] if len(metrics) == 1 else dict(metrics[0])
if len(metrics) > 1:
raise ValueError("verify-metric 仅支持单个申报案例;多个案例请放入 input 目录走 verify-batch-input")
case = dict(metrics[0])
if teacher_name:
case.setdefault("teacherName", teacher_name)
if department:
case.setdefault("department", department)
case.setdefault("applyRank", apply_rank)
final_rank = str(case.get("applyRank") or "").strip()
if final_rank not in KNOWN_RANKS:
message = f"申报级别只接受 {'、'.join(KNOWN_RANKS)}:{final_rank or '(未提供)'}"
log.warning("task_failed task_type=verify_metric error_code=RANK_INVALID stage=verify")
finish(status="failed", message=message, skill=SKILL_SLUG, error_code="RANK_INVALID", stage="verify")
print(f"ERROR:RANK_INVALID {message}")
return 1
result = run_verify([case])
brief = result["reports"][0]
@@ -419,9 +429,12 @@ def cmd_verify_metric(
def cmd_verify_batch_input() -> int:
"""批量核验(async Action 入口):核验 input/ 目录中的申报案例 JSON。"""
"""批量核验(async Action 入口):核验 input/ 目录中的申报案例 JSON。
坏文件(解析失败/非对象数组)跳过并告警,不中断整批;级别非法的案例由内核报错。
"""
log = _get_task_logger()
from service.verify_service import run_verify
from service.verify_service import collect_input_cases, run_verify
from util.runtime_paths import get_input_dir
with job_context(skill=SKILL_SLUG):
@@ -436,26 +449,17 @@ def cmd_verify_batch_input() -> int:
return 1
input_dir = get_input_dir()
files = sorted(
os.path.join(input_dir, name)
for name in os.listdir(input_dir)
if name.lower().endswith(".json")
)
if not files:
message = f"input 目录没有申报案例 JSON:{input_dir}"
cases, skipped_files = collect_input_cases(input_dir)
for entry in skipped_files:
log.warning("batch_input_file_skipped file=%s reason=%s stage=batch", entry["file"], entry["reason"])
emit(f"{entry['file']}:{entry['reason']},该文件已跳过", type="warn", skill=SKILL_SLUG, stage="batch")
if not cases:
message = f"input 目录没有可用的申报案例 JSON({len(skipped_files)} 个文件无法解析):{input_dir}"
log.warning("task_failed task_type=verify_batch error_code=METRIC_INVALID stage=batch")
finish(status="failed", message=message, skill=SKILL_SLUG, error_code="METRIC_INVALID", stage="batch")
print(f"ERROR:METRIC_INVALID {message}")
return 1
cases = []
for path in files:
with open(path, encoding="utf-8") as f:
payload = json.load(f)
if isinstance(payload, dict):
payload = payload.get("cases") or [payload]
cases.extend(payload)
result = run_verify(cases)
for index, brief in enumerate(result["reports"], start=1):
emit(
@@ -468,6 +472,7 @@ def cmd_verify_batch_input() -> int:
"batch_no": result["batch_no"],
"count": result["count"],
"verdict_counts": result["verdict_counts"],
"skipped_files": skipped_files,
"brief_file": result["brief_file"],
})
finish(status="success", message=f"批量核验完成 {result['count']} 个案例", skill=SKILL_SLUG, stage="batch", batch_no=result["batch_no"])

View File

@@ -6,12 +6,11 @@ import json
from typing import Any, Optional
from db.connection import get_conn, init_db
from db.seed_data import KNOWN_RANKS as _KNOWN_RANKS
from service.verify_service import _METRIC_ALIASES, _snake, norm_metrics
from util.logging_config import get_skill_logger
from util.timeutil import now_unix
_KNOWN_RANKS = ("副教授", "教授")
def _log():
try:

View File

@@ -19,6 +19,7 @@ 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
@@ -39,6 +40,7 @@ _METRIC_ALIASES = {
"applyRank": ("apply_rank", "申报级别", "申报职称"),
"presidingProvincial": ("presiding_provincial", "主持省部级项目", "省部级项目"),
"corePapers": ("core_papers", "核心期刊论文", "核心论文"),
"firstAuthorCorePapers": ("first_author_core_papers", "核心期刊一作/通讯论文", "一作/通讯论文"),
"teachingHours": ("teaching_hours", "年均教学工作量", "教学工作量"),
"currentTitleYears": ("current_title_years", "任现职年限", "现职称年限"),
"monographs": ("monographs", "专著/教材", "专著"),
@@ -165,25 +167,43 @@ def verify_metrics(metrics_raw: dict[str, Any], thresholds: dict[str, dict[str,
"advice": f"{advice}(依据:{basis})" if advice else "",
})
# V1:申报指标中存在但门槛库未配置的键(排除标识字段与其别名形式)
# V1:申报指标中存在、但该级别门槛库未配置的指标。
# 覆盖判定以"该级别门槛键 + 其全部别名形式"为准——已知别名但本级别未配门槛
# 的指标同样要出 V1 灰行,绝不能当作已核验而漏掉。
identity_keys = {"teacher_name", "teacherName", "department", "apply_rank", "applyRank"}
known_keys = set(thresholds.keys()) | set(_METRIC_ALIASES.keys())
unconfigured = []
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 known_keys:
continue
if _snake(key) in known_keys or _camel(key) in known_keys:
if key in identity_keys or key in covered_names:
continue
if metrics.get(key) in (None, ""):
continue
if any(key in names for names in _METRIC_ALIASES.values()):
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
unconfigured.append(str(key))
for key in unconfigured:
seen_v1.add(v1_key)
has_yellow_or_gray = True
display = str(_METRIC_ALIASES[canonical][0]) if canonical else str(key)
matrix.append({
"metric": str(_METRIC_ALIASES.get(key, (key,))[0]) if key in _METRIC_ALIASES else key,
"metric_key": key,
"metric": display,
"metric_key": v1_key,
"required": None,
"actual": metrics.get(key),
"margin": None,
@@ -195,9 +215,12 @@ def verify_metrics(metrics_raw: dict[str, Any], thresholds: dict[str, dict[str,
if has_red:
verdict = VERDICT_RED
summary = "存在不达门槛的硬伤项,暂缓提交;请按矩阵补足材料后重新核验。"
elif has_yellow_or_gray:
elif has_yellow_or_gray or not thresholds:
verdict = VERDICT_BORDER
summary = "无硬伤,但存在边缘项或待核数据,建议补充说明后提交。"
if not thresholds:
summary = f"门槛库未配置「{apply_rank}」级别的门槛,本次无法完成硬指标核验,请人事处补充配置后重新核验(V1)。"
else:
summary = "无硬伤,但存在边缘项或待核数据,建议补充说明后提交。"
else:
verdict = VERDICT_SAFE
summary = "全部硬指标超标准 10% 以上,形式审查通过。"
@@ -212,6 +235,35 @@ def verify_metrics(metrics_raw: dict[str, Any], thresholds: dict[str, dict[str,
}
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
@@ -234,7 +286,12 @@ def run_verify(cases: list[dict[str, Any]], *, batch_no: str = "") -> dict[str,
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()))
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