Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions docs/guides/same-cohort-average-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 视图。

Expand Down Expand Up @@ -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`、空值处理)后;
Expand Down
8 changes: 5 additions & 3 deletions routes/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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 = {}
Expand Down
17 changes: 17 additions & 0 deletions tests/test_same_cohort_check_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 51 additions & 0 deletions tests/test_submission_stats.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions utils/submission_stats.py
Original file line number Diff line number Diff line change
@@ -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)
Loading