Files
check-course-syllabus-aa/scripts/util/score_util.py

154 lines
4.6 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.
"""课程教学大纲体检 - 纯函数工具层。
设计原则:
- 仅依赖标准库;不依赖 jiangchang_skill_core / jinja2 / python-docx
- 这些函数是 service 层 / CLI 入口都要复用的纯计算,便于 unit test
- 输入允许 str / list[str] / dict;返回稳定标签 / 数值
模块内函数:
- clamp_score(value): 把 0~100 之外的分数裁剪到合法区间
- weighted_total(format_score, matrix_score, ideology_score, weights): 加权总分
- classify_audit_status(score, pass_thr, modify_thr): 根据总分给最终建议
- parse_score_threshold(env_value, default): 从 env 字符串读取阈值
- parse_ideology_keywords(env_value, default_keywords): 解析思政关键词 JSON 数组
- count_keyword_hits(text, keywords): 统计文本中思政关键词命中数
"""
from __future__ import annotations
import json
from typing import Iterable, Mapping
from util.constants import (
AUDIT_STATUS_MODIFY,
AUDIT_STATUS_PASS,
AUDIT_STATUS_REWRITE,
DEFAULT_WEIGHT_FORMAT,
DEFAULT_WEIGHT_IDEOLOGY,
DEFAULT_WEIGHT_MATRIX,
TOTAL_SCORE_MAX,
TOTAL_SCORE_MIN,
)
def clamp_score(value, min_val: float = TOTAL_SCORE_MIN, max_val: float = TOTAL_SCORE_MAX) -> float:
"""把分数裁剪到 [min_val, max_val] 区间。None / 非数字输入 → min_val。"""
try:
v = float(value)
except (TypeError, ValueError):
return float(min_val)
if v < min_val:
return float(min_val)
if v > max_val:
return float(max_val)
return v
def weighted_total(
format_score,
matrix_score,
ideology_score,
weights: Mapping[str, float] | None = None,
) -> float:
"""三个子项加权求和,权重默认 (0.3 / 0.4 / 0.3)。
weights 可选 dict,覆盖默认值;缺 key 时回退默认权重。
返回值经 clamp_score 裁剪到 [0, 100]。
"""
w = weights or {}
wf = float(w.get("format", DEFAULT_WEIGHT_FORMAT))
wm = float(w.get("matrix", DEFAULT_WEIGHT_MATRIX))
wi = float(w.get("ideology", DEFAULT_WEIGHT_IDEOLOGY))
total = (
clamp_score(format_score) * wf
+ clamp_score(matrix_score) * wm
+ clamp_score(ideology_score) * wi
)
return round(clamp_score(total), 2)
def classify_audit_status(
score,
pass_thr,
modify_thr,
) -> str:
"""根据总分给最终建议。
- score >= pass_thr → pass(直接送审)
- modify_thr <= score < pass_thr → modify(修改后送审)
- score < modify_thr → rewrite(重写)
"""
s = clamp_score(score)
try:
p = float(pass_thr)
except (TypeError, ValueError):
p = 0.0
try:
m = float(modify_thr)
except (TypeError, ValueError):
m = 0.0
if p < m:
p, m = m, p # 防御性:保证 pass >= modify
if s >= p:
return AUDIT_STATUS_PASS
if s >= m:
return AUDIT_STATUS_MODIFY
return AUDIT_STATUS_REWRITE
def parse_score_threshold(env_value, default: float) -> float:
"""从 env 字符串读取分数阈值;非法值回退 default。"""
if env_value is None:
return float(default)
s = str(env_value).strip()
if not s:
return float(default)
try:
v = float(s)
except ValueError:
return float(default)
if v < TOTAL_SCORE_MIN or v > TOTAL_SCORE_MAX:
return float(default)
return v
def parse_ideology_keywords(env_value, default_keywords: Iterable[str]) -> list[str]:
"""从 env(JSON 数组字符串)解析思政关键词列表。
- 空 / 非法 JSON → 默认关键词列表
- 元素需为 str;非 str 一律跳过
"""
if env_value is None:
return list(default_keywords)
s = str(env_value).strip()
if not s:
return list(default_keywords)
try:
data = json.loads(s)
except (ValueError, TypeError):
return list(default_keywords)
if not isinstance(data, list):
return list(default_keywords)
out: list[str] = []
for item in data:
if isinstance(item, str) and item.strip():
out.append(item.strip())
return out or list(default_keywords)
def count_keyword_hits(text: str, keywords: Iterable[str]) -> dict[str, int]:
"""统计文本中每个关键词出现的次数(不区分大小写)。
返回 {keyword: count};不命中的关键词不出现在结果中(count=0 省略)。
"""
if not isinstance(text, str):
return {}
lower = text.lower()
out: dict[str, int] = {}
for kw in keywords:
if not isinstance(kw, str) or not kw:
continue
c = lower.count(kw.lower())
if c > 0:
out[kw] = c
return out