Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bb3d50ddb | ||
|
|
b859a89222 |
@@ -3,6 +3,13 @@
|
||||
本文件对应技能市场 **「更新日志」** Tab。
|
||||
发布 CI 会按当前 tag 版本提取匹配的 `##` 小节,写入该版本的 `changelog`。
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- 核验更审慎:申报级别只接受副教授/教授(其它值报错);门槛库未配置的级别或指标一律不判"安全",改为"边缘"并逐项提示"门槛库未配置",杜绝误报"形式审查通过"
|
||||
- 教授门槛种子补"核心期刊一作/通讯论文 ≥3 篇"独立门槛;专著/教材条目注明"或等效获奖"由人事处人工认定
|
||||
- 批量核验容错:input 目录中无法解析的案例文件自动跳过并在任务中告警说明,不再中断整批
|
||||
- 核验一次只处理一个申报案例,多案例输入会明确提示改走批量核验
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- 首个版本:职称评审"硬指标自动核验"上线
|
||||
|
||||
2
DEMO.md
2
DEMO.md
@@ -12,5 +12,5 @@ demo_video_url: ""
|
||||
|
||||
## 演示二:师资科批量预审(数据管理)
|
||||
|
||||
1. 门槛库按最新文件核准;对话批量核验全院申报案例。
|
||||
1. 门槛库按最新文件核准;对话批量核验全院申报案例(无法解析的坏文件自动跳过并告警,不中断整批)。
|
||||
2. 汇总页按结论排序:🔴硬伤清单(附差什么)供一次性反馈,🟢安全直接进入下一环节。
|
||||
|
||||
4
SKILL.md
4
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 职称评审核验助手
|
||||
description: "职称申报硬指标自动核验:维护各级别刚性门槛库,对申报案例逐项比对生成达标矩阵与红(硬伤)/黄(边缘)/绿(安全)三色结论,出具《核验报告》。"
|
||||
version: 1.0.1
|
||||
version: 1.0.2
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -55,7 +55,7 @@ allowed-tools:
|
||||
**硬规则:**
|
||||
|
||||
1. `verify-batch-input` 是本技能唯一 async Action;禁止用 bash 跑它再轮询等待。
|
||||
2. 指标与门槛必须来自用户/门槛库,Agent 不得编造;缺数据标"待核"。
|
||||
2. 指标与门槛必须来自用户/门槛库,Agent 不得编造;缺数据标"待核"。门槛库未配置的级别或指标一律不判"安全",按"边缘/待核"提示补配(RANK_INVALID / V1)。
|
||||
3. 结论必须说明"仅硬指标形式审查,不构成评审结论";硬伤项要读出依据条款。
|
||||
4. 一个业务能力一个 Action;底层同一 `scripts/main.py` CLI。
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 本技能做什么
|
||||
|
||||
技能维护一份"评审门槛库"(按副教授/教授分级,每条门槛含最低要求与依据条款)。核验时把申报人的实有指标(省部级项目、核心论文、教学工作量、任现职年限等)逐项与门槛比对:不达标为🔴硬伤、达标但余量不足10%为🟡边缘、全部超标准10%以上为🟢安全,缺数据的标⚪待核,最终生成达标矩阵与《核验报告》。
|
||||
技能维护一份"评审门槛库"(按副教授/教授分级,每条门槛含最低要求与依据条款;教授含"核心期刊一作/通讯论文"独立门槛)。核验时把申报人的实有指标(省部级项目、核心论文、教学工作量、任现职年限等)逐项与门槛比对:不达标为🔴硬伤、达标但余量不足10%为🟡边缘、全部超标准10%以上为🟢安全,缺数据或门槛库未配置的标⚪待核,最终生成达标矩阵与《核验报告》。门槛库未配置的级别/指标一律不判"安全"。
|
||||
|
||||
## 开始前
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
|
||||
## 常见卡住怎么办
|
||||
|
||||
- **核验结论都是"边缘"**:门槛库数值可能不是学校现行标准;请管理员核准后重新核验
|
||||
- **某项显示"待核"**:该指标没有提供数值;补齐后重新核验
|
||||
- **核验结论都是"边缘"**:门槛库数值可能不是学校现行标准,或该级别门槛被清空(提示"门槛库未配置");请管理员核准后重新核验
|
||||
- **提示"申报级别只接受副教授、教授"**:申报级别填了其它值;核对级别或请管理员确认是否需扩展级别
|
||||
- **某项显示"待核"**:该指标没有提供数值,或门槛库未配置该指标;补齐数据/补配门槛后重新核验
|
||||
- **提示"门槛库未配置该指标"**:申报人有门槛库之外的指标(如获奖);请人事处在门槛库补充配置
|
||||
- **看不到核验报告**:先在"核验案例"表点一次「生成核验报告」,再到左侧 HTML 报告区打开
|
||||
- **批量核验没有结果**:确认 input 目录 JSON 是申报案例对象数组
|
||||
- **批量核验没有结果**:确认 input 目录 JSON 是申报案例对象数组;无法解析的文件会被跳过并在进度中告警说明
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(职称评审核验助手)。"""
|
||||
|
||||
SKILL_SLUG = "verify-promotion-metric-default"
|
||||
SKILL_VERSION = "1.0.1"
|
||||
SKILL_VERSION = "1.0.2"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.verify_promotion_metric_default"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"""职称硬指标核验内核金标准用例(离线、确定性)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from _support import IsolatedDataRoot
|
||||
@@ -65,6 +68,33 @@ class TestMatrixColors(VerifyRuleTestBase):
|
||||
colors = {m["metric_key"]: m["color"] for m in result["matrix"]}
|
||||
self.assertEqual(colors["teaching_hours"], "green")
|
||||
|
||||
def test_yellow_boundary_at_109_percent(self) -> None:
|
||||
# 1.09 倍(174.4 = 160×1.09,余量 9%)属黄
|
||||
result = self._verify(_metrics(teachingHours=174.4))
|
||||
colors = {m["metric_key"]: m["color"] for m in result["matrix"]}
|
||||
self.assertEqual(colors["teaching_hours"], "yellow")
|
||||
|
||||
def test_alias_metric_without_threshold_gets_v1(self) -> None:
|
||||
# 副教授种子未配置 awards:提供该指标须出 V1 灰行,不得当作已核验跳过
|
||||
result = self._verify(_metrics(awards=2))
|
||||
v1 = [m for m in result["matrix"] if m["metric_key"] == "awards"]
|
||||
self.assertEqual(len(v1), 1)
|
||||
self.assertIn("V1", v1[0]["advice"])
|
||||
|
||||
def test_empty_thresholds_never_safe(self) -> None:
|
||||
"""该级别门槛库为空 → 一律不判"安全"(V1 灰行 + 边缘结论)。"""
|
||||
from service.verify_service import verify_metrics
|
||||
|
||||
result = verify_metrics(_metrics(), {})
|
||||
self.assertEqual(result["verdict"], "边缘")
|
||||
self.assertIn("门槛库未配置", result["summary"])
|
||||
provided = [m for m in result["matrix"] if m["metric_key"] in ("presiding_provincial", "core_papers")]
|
||||
self.assertTrue(provided and all(m["color"] == "gray" for m in provided))
|
||||
|
||||
bare = verify_metrics({"teacherName": "某人", "applyRank": "副教授"}, {})
|
||||
self.assertEqual(bare["verdict"], "边缘")
|
||||
self.assertIn("门槛库未配置", bare["summary"])
|
||||
|
||||
def test_r4_missing_metric_pending(self) -> None:
|
||||
result = self._verify(_metrics(corePapers=None))
|
||||
self.assertEqual(result["verdict"], "边缘")
|
||||
@@ -106,6 +136,26 @@ class TestVerifyKernel(VerifyRuleTestBase):
|
||||
result = self._run([_metrics(rank="教授", presidingProvincial=2, corePapers=7, monographs=2)])
|
||||
self.assertEqual(result["reports"][0]["verdict"], "边缘") # core 7/6 余量 16.7% 绿、presiding 2/2 黄
|
||||
|
||||
def test_rank_invalid_raises(self) -> None:
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._run([_metrics(rank="院士", presidingProvincial=0)])
|
||||
self.assertIn("RANK_INVALID", str(ctx.exception))
|
||||
|
||||
def test_professor_first_author_threshold_enforced(self) -> None:
|
||||
"""教授"一作/通讯 ≥3"为独立门槛:缺数据判待核(边缘),提供后参与判定。"""
|
||||
from service.verify_service import load_thresholds, verify_metrics
|
||||
|
||||
base = _metrics(rank="教授", presidingProvincial=3, corePapers=7, monographs=2, currentTitleYears=6)
|
||||
without = verify_metrics(base, load_thresholds("教授"))
|
||||
self.assertEqual(without["verdict"], "边缘")
|
||||
pending = next(m for m in without["matrix"] if m["metric_key"] == "first_author_core_papers")
|
||||
self.assertEqual(pending["color"], "gray")
|
||||
|
||||
full = verify_metrics(dict(base, firstAuthorCorePapers=4), load_thresholds("教授"))
|
||||
fa = next(m for m in full["matrix"] if m["metric_key"] == "first_author_core_papers")
|
||||
self.assertEqual(fa["color"], "green")
|
||||
self.assertEqual(fa["required"], 3.0)
|
||||
|
||||
def test_set_threshold_changes_verdict(self) -> None:
|
||||
from service.threshold_service import upsert_threshold
|
||||
|
||||
@@ -152,5 +202,37 @@ class TestThresholdService(VerifyRuleTestBase):
|
||||
self.assertTrue(payload["red_cases"])
|
||||
|
||||
|
||||
class TestBatchInputCollection(VerifyRuleTestBase):
|
||||
"""input/ 目录收集:坏文件跳过并记录原因,不中断整批。"""
|
||||
|
||||
def test_collect_input_cases_skips_bad_files(self) -> None:
|
||||
from service.verify_service import collect_input_cases
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with open(os.path.join(tmp, "a.json"), "w", encoding="utf-8") as f:
|
||||
json.dump([_metrics(), _metrics(teacherName="李老师")], f, ensure_ascii=False)
|
||||
with open(os.path.join(tmp, "single.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"teacherName": "王老师", "applyRank": "副教授"}, f, ensure_ascii=False)
|
||||
with open(os.path.join(tmp, "bad.json"), "w", encoding="utf-8") as f:
|
||||
f.write("{not-json")
|
||||
with open(os.path.join(tmp, "scalar.json"), "w", encoding="utf-8") as f:
|
||||
json.dump("just-a-string", f, ensure_ascii=False)
|
||||
|
||||
cases, skipped = collect_input_cases(tmp)
|
||||
|
||||
names = [c.get("teacherName") for c in cases]
|
||||
self.assertEqual(names, ["张老师", "李老师", "王老师"])
|
||||
self.assertEqual({e["file"] for e in skipped}, {"bad.json", "scalar.json"})
|
||||
self.assertTrue(all(e["reason"] for e in skipped))
|
||||
|
||||
def test_collect_input_cases_empty_dir(self) -> None:
|
||||
from service.verify_service import collect_input_cases
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cases, skipped = collect_input_cases(tmp)
|
||||
self.assertEqual(cases, [])
|
||||
self.assertEqual(skipped, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user