# 0033 · 综合实战：把刷题助手做完 · 参考解

这一课把前面三十二课攒下来的东西收成一条**能跑完的线**：

```text
题目文本 ──①提取题意（调模型 + 三档解析 + 重试）──▶ {title, constraints, ask}
        ──②推荐候选算法（关键词表筛一遍）────────▶ {candidates, reasons, picked}
        ──③写解法（入口 + 复杂度）───────────────▶ {code, entry, complexity}
        ──④自动验证（拿固定数据对拍）────────────▶ {passed, evidence, failing_case, …}
        ──⑤出记录（数据本身 + 结论 + 怎么重跑）───▶ {problem, picked, passed, evidence, seed, rerun}
```

**五段之间每一道缝上都有契约检查**，所以出错时错误停在**它发生的那一段**，
不会掉到五层之外。这就是这一课唯一要带走的东西。

把这一份里的文件拷进你的任务目录就能直接跑（`tools.py` / `fake_model.py` / `run_tests.py`
两侧内容完全相同）：

```text
py run_tests.py        # 在 lab/solutions/0033-capstone-ai-tutor/ 目录里直接跑
py pipeline.py         # 看那条最简的线自己跑一遍
py parse.py            # 看四份真实会遇到的模型输出、三档解析器各吃下几种
py contracts.py        # 看五段的契约表
```

`tools.py` 里那几组数据（9 个钟、9 个按钮、五组测试）全是 `canon_*` 造出来的确定值，
所以**这一课一个耗时都不量**，两遍跑出来的记录必须逐字相同。

## 逐段：为什么这么写

### ① 提取题意：模型输出不可信，所以要先能「如实报坏」

四份预置输出就是四种真实会遇到的样子（`parse.py` 一点就给出来）：

```
干净 JSON: 拿下
带围栏  : 拿下
带前言  : 拿下
被截断  : 不行
```

| 那一份 | 长什么样 | 裸 `json.loads` | 剥围栏 | 提取首个平衡对象 |
| --- | --- | --- | --- | --- |
| 干净 JSON | 一个 JSON 对象 | 过 | 过 | 过 |
| 带围栏 | 三行：围栏、内容、围栏 | 失败 | 过 | 过 |
| 带前言 | 前面一段话、后面一段话 | 失败 | 失败 | 过 |
| 被截断 | 开头对、第 64 个字符断在半截 | 失败 | 失败 | **失败** |

**被截断那一档，再聪明的解析器也救不回来。** 判据很干脆：**找得到 `{`，
却找不到配对的 `}`**——坏的不是格式，是内容不完整。所以 `parse_json` 对它交出的不是
「JSON 语法错在哪儿」，而是一句能让上游做决定的说明：

```text
括号不闭合（找得到 { 却找不到配对的 }）——这是**被截断**，不是格式问题
```

有了这个判据，第一段才敢做重试：**先解析，失败就带着坏消息再问一次**。
跑一遍看得到两个数：

```
第一段调用了几次模型: 2
第二次的 retry 标记: True
两次拿到的输出一样吗: False
第一次那一份是截断的吗: True
重试之后的标题: 拨钟问题
```

调两次不是「多试一次运气」：第二次的提示词里明写着上一次被截断了
（所以假模型才会换一份干净的交出来）。**被截断走的是重试，不是静默吞掉。**

两次都失败呢？第一段交回一个**占位**（`title` 写成「（提取失败：括号不闭合…）」），
不抛异常、也不交回 `None`：整条链要带着「这一段坏了」这个事实继续走完，
记录里才有东西可写。第一段直接抛出去的话，你连「它坏在哪」都留不下来。

### ② 推荐算法：模型说什么不算数，筛一遍才算

第 2 段的产品是**候选表 + 选定的那一个**，不是一个字符串。`FAMILY_KEYS` 那张表
按题目关键词筛：命中「最少按键次数 / 最短步数 / 走法 / 状态空间 / 扩散」的族留下。
筛完只剩 `['图的搜索']`，`picked` 就跟着它走——**`picked` 永远落在 `candidates` 里**，
这一点判分单独查一条。

留候选表而不是只留结论，是因为「筛掉了什么」本身是信息：模型给的族名不在表里时，
你会看到候选表比它给的短，那就是该去扩表（或者改用真模型再问一次）的信号。

### ③ 写解法：入口与复杂度一并交出来

`{code, entry, complexity}` 三格。`entry` 是**人能照着调**的那一句
（`solve(text)`），第 4 段就靠 `exec` 它跑数据——所以第三段与第四段之间那道缝是硬的：
名字错了、形状错了，就在那一道缝上停住，不会等到「跑出个怪答案」才发现。

这一课第 3 段的解法自己枚举每个按钮按 0..3 次的全部 4^9 种组合，取总次数最小的那一个
（复杂度那一格写的是 `O(4^n · n)`）。这是**故意的**：它慢但一定对，
所以第 4 段拿到的是一个可信的被测对象。

### ④ 自动验证：结论必须是算出来的

`run_cases` 拿五组固定数据跑第 3 段的解法，逐组跟期望比：

```
第 0 组 0 0 0/0 0 0/0 0 0 → 算出来 0  / 期望 0  → 过
第 1 组 0 0 1/0 0 1/0 0 1 → 算出来 1  / 期望 1  → 过
第 2 组 0 1 2/3 0 1/2 3 0 → 算出来 8  / 期望 8  → 过
第 3 组 1 2 3/0 1 2/3 0 1 → 算出来 13 / 期望 13 → 过
第 4 组 2 1 0/3 2 1/0 3 2 → 算出来 18 / 期望 4  → 没过
```

第 4 组那一行的期望值是**故意写错的**。它换来两样东西：

1. 「不合格时的第一个反例」那一格有东西可写（组号、数据、算出来的、期望的四样）；
2. 整份记录的结论是 `False`——一份**永远说通过**的验证器等于没有。

判分为此塞了两个沙箱进去试：一个是**全错**的（五组必须全没过、第一个反例落在第 0 组），
一个是把期望值换成真值的。**把「通过吗」写死的实现在这里必露。**

期望值本身由 `tools.canon_exec` 现算（枚举 4^9 种按键组合，取总次数最小的），
**不是照着解法的输出填的**：照着自己的输出填期望值，那一栏就永远等于「通过」。

### ⑤ 出记录：留数据本身，不是只留 seed

```
记录里的证据条数: 5
第一个反例: 第 4 组: 2 1 0 3 2 1 0 3 2 算出来 18 / 期望 4
记录的 seed: 20260301
两遍的记录一样吗: True
```

`seed` 保证的是「同一个环境下、同一段代码、同一个种子，重现同一批数据」。
它没保证三件事：

1. 换了 Python 版本，`random` 的实现可能变——同一个种子的含义就变了；
2. 你在别处多抽了一次随机数，整个序列就错位了；
3. 别人拿到你的记录，还得先有你的**生成器代码**才能重放。

所以记录里留的是**数据本身**：五组输入、五组期望、五组算出来的值，一组一行。
`seed` 留着是因为它便宜（重跑一次对得上，是最快的自检），
但**只有 seed 的记录不是证据**。

### 那件最危险的事：`passed` 变成字符串

`pipeline.py` 最后一段是这一课真正的高潮：

```
契约检查放过了它吗: False
契约检查怎么说漂移后那一格: 4 验证: 字段 passed 应为 bool，实际是 str
漂移之后输出里写的结论: 验证 通过
正确答案: 验证 未过
静默地当成「通过」了吗: True
不查契约时报告会写错吗: True
judge 吃这一份会怎样: 4 验证: 字段 passed 应为 bool，实际是 str
同一份合格记录上 judge 交回: 验证 未过
```

```python
passed = 'False'          # 该是 True / False 本身
bool('False')             # → True，非空字符串在布尔语境里就是 True
```

不拦它，报告那一行会**静默地**写成「验证 通过」，而正确答案是「未过」。
它不抛异常、不报错、也没人会发现——**这正是它危险的地方**。
所以第 4 段的契约里那一格是 `is_bool`：`True` / `False` 本身才算数，
`1` / `0` / `'True'` / `'False'` 都拦住，而且要在边界上**说清是哪个字段**
（错误信息就是那一行 `4 验证: 字段 passed 应为 bool，实际是 str`）。

## 常见错法（判分逐条盯着）

| 错法 | 症状 | 判分读的那一条 |
| --- | --- | --- |
| 第 4 段用 `is_str` 之类查 `passed` | 字符串 `'False'` 被放过去 | `第 4 段的契约**放过了** passed 是字符串的那种记录` |
| 契约不对时交回 `False` 而不是抛 | 下游顺手 `if not x` 吃掉，接着算 | `漂移那一条被当场拦住，而且说清了…` |
| 只写 `print`、没真算 | 各段那几行全是占位 | 判分另起探针 `import` 你的函数自己算 |
| 第一段不重试、直接交回半截 | 只调一次模型 | `第一段调用模型 2 次` |
| 重试时不带 `retry=True` | 第二次还是半截 | `第二次调用没有带 retry=True` |
| 重试时换一份提示词、把关键词丢了 | 假模型交回别的东西 | `两次调用的样子都对` |
| 把被截断那份「修」成能解析 | 半截 JSON 成了半个字典 | `这一档不该救得了它` |
| 第三档不跳字符串里的花括号 | `{"a": "x{y}z"}` 解不出 | 探针「字符串里带花括号」 |
| 解析失败直接放 `JSONDecodeError` | 上游看不懂该重试还是改提示词 | `没找到 {` 与 `括号不闭合` 要各说各的 |
| `picked` 不落在 `candidates` 里 | 结论与候选分家 | `picked 落在 candidates 里` |
| `run_cases` 把期望值也一起算 | 那几组永远「过」 | 塞一个**全错**的沙箱进去，五组必须全没过 |
| `passed` 写死 `True` | 换沙箱也不变 | 同上 |
| `results` 里放 `1` / `0` | 类型不对 | `每一格都要是 True / False 本身` |
| `failing_case` 只给组号 | 别人复现不出来 | `第一个反例说清了四样` |
| 一组都没失败时 `failing_case` 给 `{}` | 「没有反例」与「没算」分不开 | `failing_case 是 None` |
| 记录里只写 seed | 换台机器就不可重放 | `证据里带着**数据本身**` |
| 两遍结论不一样 | 有人塞了不确定的东西 | `两遍的记录逐字相同` |
| 改函数名 / 改判分要读的 `print` 行 | 判分「没看到某一行」 | 所有「输出里没有…」的 FAIL |
| 动了 `tools.py` / `fake_model.py` | 四份预置输出与那几组数据全对不上 | 逐字节比 SHA-256 |
| 源码里手写连续三个反引号 | 打断阅卷工具链 | 全树扫一遍 |

## 判分在读什么

`run_tests.py` 分五层，103 条检查：

| 层 | 验什么 | 关键证据 |
| --- | --- | --- |
| 1 | 契约表五行齐全、段号 1..5、五段的关键字段都点到；`CONTRACTS` 五段都在；**`passed` 那格拦住字符串 `'False'`**；检查器放过合格记录、拦住漂移记录并点名字段 | 判分自己造的 `GOOD`（五段各一份合格产物）+ 两份漂移记录 |
| 2 | 四份预置输出逐档过：干净三档都行、带围栏从第二档起、带前言只有第三档行、被截断三档都不行 | 判分自己造的探针：嵌套对象、字符串里带花括号、没有花括号、括号不闭合 |
| 3 | 四段各自独立验（不等整条链）：第一段调了几次模型、第二段筛完剩什么、第四段在**判分自己的沙箱**上算得对不对、`judge` 跟着 `passed` 变；再把五段串起来，注入 `passed → 'False'` 与 `title → name` 看错误停在哪一段 | `run_cases(解, 全错的沙箱)` 必须五组全没过；`count_calls` 数整条链上契约查了几次 |
| 4 | 记录里有数据本身（0 0 0 / 0 0 1 / 2 1 0 都在）、第一个反例四样齐全、两遍记录逐字相同、全过时 `failing_case` 是 `None` | 两次完整 `run_pipeline` + 判分自己造的一份「全过」结果 |
| 5 | 四个脚本逐行读、单跑退出码 0 且 0 处 Traceback、`tools.py` / `fake_model.py` 逐字节相同（SHA-256）、无连续三个反引号、`answer.md` 有「我不放心的地方」且带触发条件 | 判分子进程真跑一遍，再按标签读行 |

两路证据缺一不可：

1. **跑任务脚本、读它自己打印的行**（主要证据）。判分按**标签**找行（不按编号），
   行里的值都是从函数交回的东西里查出来的——留白版照样把这些行原样打出来，
   只是值是占位的。
2. **另起探针 `import` 学生的函数，用判分自己造的数据试**（独立证据）。
   探针的数据**刻意与脚本里那一批不同**（嵌套对象、字符串里带花括号、
   全错的沙箱、`title → name` 的字段漂移）。**只写 print 不写实现拿不到 PASS。**

判分里核对的是那些**确定量**：`拨钟问题` / `图的搜索` / `0 1 8 13 18` /
`[True, True, True, True, False]` / `20260301` / 第 4 组 `18` vs 期望 `4`，
以及**没有一条耗时断言**（本课一个时间都不量，所以不会出现计时的抖动）。

留白版的成绩是 `0 层通过、5 层还没过（共 29 条 PASS、55 条 FAIL）`，
参考解是 `5 层通过、0 层还没过（共 103 条 PASS、0 条 FAIL）`，
连跑三次都是退出码 0、三次输出逐字节相同
（输出 SHA-256 `345A9D8526FF8FB7…`）；留白版的 55 条 FAIL **每一条都点明了**
是哪一段的 TODO（不看 TODO 就知道该补哪儿的一条都没有）。

留白版四个脚本单跑都是退出码 0、0 处 Traceback，打印行数与参考解**逐行相同**：

```text
contracts.py   18 行
parse.py       21 行
fake_model.py   0 行
pipeline.py    30 行
```

`contracts.py` 与 `pipeline.py` 的行数是**固定的**：还没接上时每一格打占位
（`（还没接上）`），接上之后就是真值——**行数不许随进度变**，因为判分读的就是这几行。

## 逐字节相同的三份

```text
tools.py       5E047339463A67BF47AB5D83E6096E611A206462938FA44B674A4BB2B96C9740
fake_model.py  B76CE3586F34AACC8B51AF3EFF9EFFD87FF6283440D78023B4AB688FA1E1A288
run_tests.py   6B0B0D4B14CB4859…（这一份就是判分自己）
```

这三份在留白版与参考解里**逐字节相同**，落盘一律 LF、结尾都有换行；
`run_tests.py` 第 5 层就是用上面两个哈希核对 `tools.py` 与 `fake_model.py` 的。

## 换成真模型只改一处

`fake_model.py` 的 docstring 里留了那段改法：把 `call_model(prompt, retry=False)`
的函数体换成一次 DeepSeek 调用，其余四段一行都不用改。**缝没留对的话，
接真 API 的时候要改五个文件**——这就是为什么这一课把模型调用收成一处。
