"""课程教学大纲体检 - 纯函数工具层。 设计原则: - 仅依赖标准库;不依赖 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