diff --git a/docs/guides/same-cohort-average-check.md b/docs/guides/same-cohort-average-check.md index 1255a03..d5e5bc1 100644 --- a/docs/guides/same-cohort-average-check.md +++ b/docs/guides/same-cohort-average-check.md @@ -45,16 +45,38 @@ None"两种语义合进同一分支,导致批量列表 N+1。教训与本方 下次写或评审聚合代码时按此步骤走一遍: 1. **找聚合**:定位 `sum / avg / mean / 比例 / 百分率` 等计算,包括 SQL 聚合函数。 -2. **写两个集合**:明确列出分子实际累加的是哪些记录、分母实际计数的是哪些记录。 -3. **标口径差异**:逐条检查两个集合的过滤条件是否一致——`None` 处理、 - `if x` 这类 truthy 过滤、SQL `WHERE` 条件、空集合默认值。 +2. **写两个集合 + 标内部转换**:明确列出分子实际累加的是哪些记录、分母实际 + 计数的是哪些记录;同时标出集合**内部**的值转换,例如 `x or 0`、 + `coalesce(x, 0)`、`x if x is not None else 默认值`。 +3. **核实业务定义,再标口径差异**:先找同一指标在系统中的**其他计算点** + (同文件、统计刷新函数、SQL 视图)作为对照口径,并确认页面/字段的业务 + 含义。然后检查两处: + - 跨集合差异:两个集合的过滤条件是否一致——`None` 处理、`if x` 这类 + truthy 过滤、SQL `WHERE` 条件、空集合默认值。 + - 集合内部的默认值替换:是**有意口径**(多处一致、能对上业务定义)还是 + **孤立替换**(仅此一处不同、疑似图省事)。注意:不要因为代码长得像 + 历史缺陷就直接判危险——模式只是线索,业务口径证据才是依据。 4. **统一到同一队列**:让分子分母来自同一批记录;缺失时显式给中性结果或 提前返回,不要让缺失项静默进入分母。 5. **RED → GREEN 验证**:构造一个"缺失项分布不均"的输入(缺失只出现在 分子侧或只出现在一侧分组),确认修复前断言失败、修复后通过;再补一个 全部有值的正常路径用例,确认正常路径不被改坏。 -## 3. 用新例子验证方法:首页平均分查询 +## 3. 默认值与中性态怎么选 + +同一份"没有已评分数据"的情况,在不同位置需要不同回退值,选择依据是 +**该字段的角色**: + +| 场景 | 回退值 | 理由 | +|---|---|---| +| 评分公式内部的分量(如 φ_grad) | 中性 **50** | 分量要参与加权,0 会把总分拉向"极差",50 表示"无证据,不偏不倚" | +| 展示层的统计数字(平均分、最高分) | **0**(或显示"暂无评分") | 面向用户的"无数据"回退,页面通常以 0 为兜底 | +| 计数 / 完成率 | **0** | 没有记录就是 0,不存在"中性计数" | + +关键区分:**"没有数据"回退为 0 ≠ "把缺失记录当 0 分参与计算"**。前者不 +进入分子分母(集合外的兜底),后者让缺失项稀释均值(集合内的错误转换)。 + +## 4. 新例子验证方法:首页平均分查询 **位置**:`routes/main.py` 的 `home` 视图。 @@ -88,7 +110,41 @@ scores = [s.score for s in rows] average = sum(v for v in scores if v is not None) / len(scores) ``` -## 4. 何时使用这条方法 +## 5. 带教案例:新手在"同队列内 or 0"上的误判风险 + +**位置**:`routes/assignments.py` 学生提交历史页(`submission_history`)。 + +旧代码: + +```python +total_submissions = len(submissions) +average_score = sum(s.score or 0 for s in submissions) / total_submissions if total_submissions > 0 else 0 +best_submission = max(submissions, key=lambda s: s.score or 0) if submissions else None +best_score = best_submission.score if best_submission else 0 +``` + +**新手第一轮的表现**:一名无项目背景的成员仅按本文档检查,快速判了"危险"。 +方向虽然正确,但论证是**拿本文 §1 的缺陷模式机械对号入座**,没有核实业务 +口径;若换一个"有意把未评分按 0 计"的场景,同样的推理会产出误报。 + +**按升级后的步骤 3 核实业务定义**(事实证据): + +- 同文件 L546、L588 两处平均分都用 `if s.score is not None` 排除未评分; +- 官方统计刷新 `tasks/submission_tasks.py` 只取 `status=="evaluated"` 且 + `score is not None` 的提交; +- 全系统三处口径一致排除未评分,**仅此一处不同** → 属于"孤立替换", + 没有证据支持其为有意口径,判定为真实缺陷。 +- 附带发现:全部未评分时 `best_score` 实际为 None,模板 `%.1f` 格式化会出错。 + +**修复**:统计提取为纯函数 `utils/submission_stats.py` 的 +`submission_score_stats`,分子分母只含已评分提交,无数据回退 (0, 0), +展示层 0 口径符合 §3 规则;`routes/assignments.py` 改为调用该函数。 + +**复现结果**:`tests/test_submission_stats.py` 5 个用例(含未评分 +`[40,40,None,43]`→41/43、全未评分→0/0、空列表、正常路径、真实 0 分) +全部通过。 + +## 6. 何时使用这条方法 - 评审包含均值、比率、完成率、得分汇总的改动时; - 改动过滤条件(尤其新增/放宽 `None`、空值处理)后; diff --git a/routes/assignments.py b/routes/assignments.py index 88caba5..ac82de4 100644 --- a/routes/assignments.py +++ b/routes/assignments.py @@ -62,6 +62,7 @@ from datetime import datetime from utils.sse import sse_event, sse_response, wants_sse from utils.export_safety import safe_export_cell +from utils.submission_stats import submission_score_stats assignments = Blueprint('assignments', __name__) @@ -1404,9 +1405,10 @@ def submission_history(assignment_id): # 计算提交统计信息 total_submissions = len(submissions) - average_score = sum(s.score or 0 for s in submissions) / total_submissions if total_submissions > 0 else 0 - best_submission = max(submissions, key=lambda s: s.score or 0) if submissions else None - best_score = best_submission.score if best_submission else 0 + # 口径与同文件其他平均分(仅统计已评分提交)及官方统计保持一致: + # 未评分提交不进分子也不进分母,无已评分提交时回退 0; + # best_score 同时由该纯函数给出,避免全未评分时泄漏 None。 + average_score, best_score = submission_score_stats(submissions) # 按时间分组的提交 submissions_by_date = {} diff --git a/tests/test_same_cohort_check_doc.py b/tests/test_same_cohort_check_doc.py index 4fdf58f..56c89d3 100644 --- a/tests/test_same_cohort_check_doc.py +++ b/tests/test_same_cohort_check_doc.py @@ -41,3 +41,20 @@ def test_guide_validates_method_with_new_func_avg_example(): # 必须给出可核验的明确判定,而不是只把例子摆出来 assert "安全" in text assert "忽略" in text # SQL AVG 忽略 NULL 是判定理由 + + +def test_guide_adds_business_definition_verification_and_default_rules(): + """阶段十三升级:防止新手机械套用模式误报,文档必须包含: + + - 判定前核实业务定义 / 对照口径的步骤; + - 默认值与中性态选择规则; + - 带教案例(含新手第一轮表现与复现结果)。 + """ + text = _read() + assert "业务定义" in text + assert "对照口径" in text + assert "孤立替换" in text + assert "中性" in text + # 带教案例引用了提取出的纯函数 + assert "submission_score_stats" in text + assert "复现结果" in text diff --git a/tests/test_submission_stats.py b/tests/test_submission_stats.py new file mode 100644 index 0000000..602f1c9 --- /dev/null +++ b/tests/test_submission_stats.py @@ -0,0 +1,51 @@ +"""submission_history 页分数统计的纯函数测试。 + +统计逻辑提取到 ``utils/submission_stats.py``(仅标准库,不 import Flask、 +不连数据库/Redis、不访问网络)。用 SimpleNamespace 轻量假对象验证。 + +覆盖背景:routes/assignments.py 学生提交历史页旧实现把未评分提交当 0 +(sum(s.score or 0) / len(全部)),与同文件 L546/L588 及官方统计口径 +不一致;且全未评分时 best_score 泄漏 None。 +""" +from types import SimpleNamespace + +from utils.submission_stats import submission_score_stats + + +def _sub(score): + return SimpleNamespace(score=score) + + +def _stats(scores): + return submission_score_stats([_sub(v) for v in scores]) + + +def test_average_uses_only_scored_submissions(): + # [40, 40, 未评分, 43]:未评分不进分子也不进分母 → 123/3 = 41 + average, best = _stats([40, 40, None, 43]) + assert average == 41 + assert best == 43 + + +def test_neutral_zero_when_all_submissions_unscored(): + # 全部未评分 = 无数据:avg/best 均回退 0,best 不再泄漏 None + average, best = _stats([None, None]) + assert average == 0 + assert best == 0 + + +def test_empty_submission_list_returns_zero_pair(): + assert submission_score_stats([]) == (0, 0) + + +def test_normal_path_unchanged(): + average, best = _stats([70, 80, 60]) + assert average == 70 + assert best == 80 + + +def test_real_zero_score_is_not_treated_as_missing(): + # 0 是真实分数:参与平均与最佳,不被当成未评分丢弃 + average, best = _stats([0, 80]) + assert average == 40 + assert best == 80 diff --git a/utils/submission_stats.py b/utils/submission_stats.py new file mode 100644 index 0000000..b406b34 --- /dev/null +++ b/utils/submission_stats.py @@ -0,0 +1,26 @@ +"""提交分数统计的纯函数。 + +从路由中提取,便于不依赖 Flask/数据库直接测试。口径与同项目其他平均分 +计算保持一致:未评分提交(score is None)既不进分子也不进分母;没有任何 +已评分提交时属于"无数据",回退为 0,而不是把未评分当 0 分拉低均值。 +""" +from typing import Iterable, Tuple + + +def submission_score_stats(submissions: Iterable) -> Tuple[float, int]: + """返回 ``(average_score, best_score)``,仅统计已评分提交。 + + - 平均分:sum(score) / 已评分条数; + - 最高分:已评分分数的最大值; + - 无已评分提交(含空列表、全部未评分):返回 ``(0, 0)``。 + + 注意真实 0 分是有效分数,会正常参与计算,不会被当成缺失。 + """ + scores = [ + submission.score + for submission in submissions + if getattr(submission, "score", None) is not None + ] + if not scores: + return 0, 0 + return sum(scores) / len(scores), max(scores)