# 说明（填好的样子）· 0033 综合实战：把刷题助手做完

> 这一份是参考解的说明（`answer_example.md`）。它按「每个环节为什么这样设计」写，
> 最后一节是**我不放心的地方**——那一节比前面五节都值钱。

## 1 · 五段流水线是什么

题目文本进，带证据的记录出。五段各自只干一件事：

| 段 | 输入 | 输出（契约） |
| --- | --- | --- |
| 1 提取题意 | 题目文本（str） | `{title: str, constraints: {n_max: int, value_max: int}, ask: str}` |
| 2 推荐算法 | 第 1 段的 dict | `{candidates: [str], reasons: [str], picked: str}` |
| 3 写解法 | 第 2 段的 dict | `{code: str, entry: str, complexity: str}` |
| 4 验证 | 第 3 段的 dict + 数据 | `{passed: bool, evidence: [str], failing_case: str or None}` |
| 5 出记录 | 第 4 段的 dict | `{problem, picked, passed, evidence, seed, rerun}` |

**为什么先写契约再写实现**：流水线最常见的死法不是某一段写错，而是段与段之间对不上。
把契约写在前面，第 4 段与第 5 段就能**分别开发、分别测**——判分里的第 3 层正是这么做的：
它不跑整条链，而是单独把 `run_cases` 拿出来，塞一个自己的沙箱进去看结论对不对。
没有契约，你只能在整条链跑起来的时候才发现字段名对不上，而那时候你根本不知道是哪一段的问题。

## 2 · 模型调用收成一道缝

```python
def call_model(prompt, retry=False):
    """提示词进、一段文本出。"""
```

整条链上**只有这一处**是联网点。它有两个要点：

1. **交回的是文本，不是结构化数据。** 所以第一段拿到的永远是一段要解析的东西；
2. **`retry` 这个参数是设计出来的。** 第一次问，它可能交回被截断的半截；
   带着「上一次被截断了」再问一次，它才有机会给出完整的。重试不是「再试一次运气」，
   是把上一轮的失败**告诉**它。

换成真 API 只改这一处（`fake_model.py` 的 docstring 里留了那段改法），其余四段一行都不用改。
这一课不要求你有 key，但要求你想清楚**缝在哪儿**——缝没留对，接真 API 的时候要改五个文件。

## 3 · 模型输出不可信：三档解析

四份预置输出就是四种真实会遇到的样子：

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

**被截断那一档，再聪明的解析器也救不回来。** 它不是格式问题（括号的位置没错），
是内容不完整（那个对象根本没写完）。判据很干脆：**找得到 `{`，却找不到配对的 `}`**。
所以解析器对它只能做一件事——如实交回一句「括号不闭合（找得到 { 却找不到配对的 }）」，
让上游知道该换的不是解析器，而是**重试**。

这一课里重试发生在第一段：先解析，失败就带着坏消息再问一次。
两次都失败才交回占位（`title` 写成「（提取失败：括号不闭合…）」）——
**不抛异常、也不交回 None**：整条链要带着「这一段坏了」这个事实继续走完，
记录里才有东西可写。如果第一段直接抛出去，你连「它坏在哪」都留不下来。

## 4 · 契约检查放在哪儿

每两段之间都查一次：`intent` → `recommend` → `build_solution` → `run_cases` → `make_record`，
每一步的产物交给下一段之前先对一遍契约。**契约不对就抛 `TypeError`**，不是交回 False——
下游拿到的是「这一段能不能用」这个结论，一个 False 很容易被顺手 `if not x` 吃掉、
然后接着往下算；抛出来是为了让错误停在**它发生的那一段**。

`passed` 变成字符串 `'False'` 那件事值得单独说：

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

不拦它，报告那一行会**静默地**写成「验证 通过」，而正确答案是「未过」。
它不会抛异常、不会报错、也不会有人发现——这正是它危险的地方。
所以第 4 段的契约里那一格是 `is_bool`：`True` / `False` 本身才算数，
`1` / `0` / `'True'` / `'False'` 都拦住。判分拿这一条单独试了三遍。

## 5 · 记录里为什么留数据本身

`seed` 保证的是「**同一个环境下**、同一段代码、同一个种子，会重现同一批数据」。
它没保证的东西有三样：

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

所以记录里要留**数据本身**：五组输入、五组期望、五组算出来的值，一组一行。
有了这些，别人拿一张纸就能重放；只有 seed，他得先猜出你的生成器长什么样。
`seed` 留着是因为它便宜：重跑一次对得上，是最快的自检。

## 6 · 我不放心的地方

这一节是这一课真正要交的东西。下面五条，每条都写清**触发条件**：

1. **不放心的地方：把题目文本原样塞进提示词，模型可能读偏。**
   **触发条件**：如果题目里带了样例（「输入 3 行，每行 3 个整数」后面还有样例数据），
   模型有可能把样例里的数字当成约束条件——比如把样例里最大的那个数当成 `value_max`。
   现象是 `constraints` 里的数比值域小，而**契约查不出来**（它是合法的 int）。
   我现在的做法是拿 `constraints` 只做参考、不参与判定；要真用它，得再对一次题目原文。

2. **不放心的地方：关键词筛候选会误杀。**
   **触发条件**：如果模型给出一个合适的族名，但我的 `FAMILY_KEYS` 里没有它
   （比如它说「BFS」而不是「图的搜索」），那个候选会被整条筛掉，`picked` 就空了。
   现象是候选表比模型给的短、或者 `picked` 写着「一条关键词都没命中」。
   这是「拿关键词表当尺子」的固有代价：表越窄，误杀越多。

3. **不放心的地方：`exec` 跑模型写的代码只适合这一课。**
   **触发条件**：如果第三段的解法不是自己写的、而是模型交回的，那 `run_cases`
   实际上在执行一段没审过的代码——里面有 `while True` 就会把整条链挂住，
   有 `import os` 就能碰文件系统。这一课的我自己的解法所以没事；
   接真模型之后，这里必须先过一遍白名单（或者丢进子进程加超时），我没有做。

4. **不放心的地方：那五组数据是我自己挑的，不是随机抽的。**
   **触发条件**：如果解法只在「解比较小」的输入上错（比如某个剪枝把大解漏掉了），
   这五组里最大的一组答案也只有 13，很可能照样全过。
   现象是记录里 5 组全过、换一组数据就错——所以我在数据里留了一组**故意对不上**的，
   让「不合格时的第一个反例」这一格有东西可写，也让「全过」这件事不能靠运气蒙。

5. **不放心的地方：「拨到 12 点」的换算只在这一种钟面上成立。**
   **触发条件**：如果题目给的按钮表（每个按钮拨动哪几个钟）与 `tools.BUTTONS` 不一致，
   第 3 段的解法哪怕逻辑对，答案也会错——而它错得**很有规律**（每组都差同一个量）。
   现象是记录里所有组一起错，而不是零星错一两组。
   我现在的做法是照抄经典的那张表，并且在 `CANON_TEXT` 里把题目原文写清楚；
   真去评测系统上做题时，这一条要先核对。

（另外：这一课一个耗时都不量。假模型的输出是预置的、数据是固定的，
所以每一次跑的结论都必须**逐字相同**——如果哪天两遍的结论不一样了，
那说明有人往链上塞了不确定的东西，那本身就是一条要查的线索。）
