规约的验收标准只有一条:人和 Harness 只读写下的文字,就能把它实现,也能判断有没有实现到位。
把这份规约交给一个没见过项目的人,他能不能不看代码就写出测试?
答「不能」,说明还有场景缺给定条件或缺期望值。先补那一面,再交给助手实现——实现之后补,等于照着代码写规约。
这三条里最容易跳过第三条。边界不写,写代码的人(或助手)就会自己挑一个默认值,而默认值通常不是想要的。判断一条失败路径该不该写,只问一句:把它删掉,写代码的人会不会做错?会,就写。
格式固定为 Given / When / Then,在规约文件里写作用 Scenario:。一个场景只描述一件事,期望写具体值。下面这份是上一页那次跨班检索修正的规约原文,五个场景里两个正常路径、三个失败路径。
顶部三行是前置元数据:冻结状态、覆盖的接口、检查入口。少了检查入口,这份规约就没接上门禁。
# 班级资料隔离
- 状态:已定,2026-10-03
- 覆盖接口:POST /api/kb/search、GET /api/kb/doc/{id}
- 检查入口:make check(pytest tests/spec/test_class_isolation.py)
## Requirement: 检索按班级隔离
学生只能检索到与自己同班的材料。
### Scenario: 本班材料可检索(正常路径)
- Given 学生 A 属于一班,一班已上传「数据库实验讲义」
- When 学生 A 检索关键词「实验」
- Then 状态码 200,结果含标题「数据库实验讲义」
- And 返回结果条数不超过 20
### Scenario: 他班材料不可检索(越权)
- Given 学生 A 属于一班,二班已上传「期末复习题」
- When 学生 A 检索关键词「期末」
- Then 状态码 200,结果为空
- And 响应体不含字符串「期末复习题」
- And 记一条 warning:user_id / class_id / query / ts
### Scenario: 直接按 id 取他班文档(越权,绕过检索)
- Given 学生 A 属于一班,文档 42 属于二班
- When 学生 A 请求 /api/kb/doc/42
- Then 状态码 404,不返回任何正文
- And 记一条 warning,字段同上
### Scenario: 未登录(非法输入)
- Given 请求未携带会话
- When 调用 /api/kb/search
- Then 状态码 401,响应体不含任何材料片段
### Scenario: 检索服务超时(外部依赖失败)
- Given 检索后端连续超时
- When 学生 A 检索
- Then 一次请求最多重试 1 次;仍失败返回 503
- And 响应体 message 固定为「检索暂不可用」
- And 不返回任何没有来源的片段
| 别这么写 | 为什么不行 | 改成 |
|---|---|---|
| 系统应保证数据安全 | 没有可判断的边界 | 写出谁不能对谁做什么,命中后的状态码与日志字段 |
| 检索结果要准确 | 「准确」没有阈值 | 给一组问题集与判分规则,写出答对比例下限(见下一节) |
| 复用现有实现(未限定范围) | 没说什么时候算重复 | 新增工具函数前全仓搜索同名与同签名,重复实现视为不通过 |
| 失败时给出友好提示 | 「友好」不可执行 | 写出状态码、响应字段名、固定文案、日志级别 |
| 系统响应要快 | 没有数、没有样本量 | p95 ≤ 500ms,样本 200 次,由 make perf 输出 |
| 支持并发访问 | 没有并发数与错误率上限 | 50 并发持续 60 秒,错误率 0,503 出现次数 ≤ 1 |
| 日志要记全 | 不知道记哪几个字段 | 字段固定为 user_id / class_id / query / ts,级别 warning |
| 代码要清晰 | 无法判断 | 圈复杂度 ≤ 10、单函数 ≤ 50 行,由 make check 判 |
| 注意权限 | 没有可执行的检查 | 每个越权场景写出期望状态码,并配一条测试 |
| 要兼容旧数据 | 没给旧数据示例 | 附一条旧格式示例与迁移后期望的 JSON,配一条迁移测试 |
### Scenario: 检索结果准确 - Given 用户检索关键词「实验」 - When 调用 /api/kb/search - Then 返回结果准确,响应迅速 ← 两个词都判不了 ### Scenario: 检索失败 - Given 检索后端异常 - When 用户检索 - Then 给出友好提示 ← 不可执行
认它的办法:随便挑一条 Then,能不能写成一句断言。「准确」写不出断言,「结果条数不超过 20」写得出。助手写规约时措辞很规范,问题从来不在措辞——它给的是评价而不是值。凡是读起来像好评的 Then,都是还没写完的那一条。
不要把失败路径当附录,它和正常路径平级。一门课里值得写进规约的失败路径通常只有四类,按覆盖面排下来。每一类的期望行为都要写成具体值,而不是「处理好」。
| 类别 | 典型场景 | 期望行为(写进规约的值) | 检查怎么落 |
|---|---|---|---|
| 越权与隔离 | 学生访问他班材料、普通用户调用管理接口 | 返回 404,不泄漏资源是否存在;同时记一条 warning | 每个越权场景配一条测试,跑 pytest -k spec |
| 输入不合法 | 字段缺失、超长、类型不对、重复提交 | 返回 400 并在响应体给出字段名;重复提交返回同一结果,不新建 | 同一场景用参数化输入跑多组 |
| 外部依赖失败 | 模型超时、限流、知识库无结果 | 不伪造:无结果返回空并说明;超时重试 ≤ 1 次后返回 503 | 用打桩把依赖设为失败,跑一遍 |
| 状态不合法 | 在错误状态下调用正确的接口 | 返回 409,响应体写明当前状态与允许的状态 | 一条测试从错误状态开始 |
边界:这四类之外,业务特有的失败(例如评分区间重叠)也要写。但不要为了凑数,把不可能发生的分支写进规约。判断标准还是那句——写代码的人有没有可能猜错。猜不错的,不写。
有些要求天生没有单一期望值,例如「检索要准确」「回答要有依据」。这类要求只靠场景判不了,必须配一份问题集和一份判分规则。问题集里要故意放进三种题:本班有答案、他班有答案、问题集里根本没有答案。第三种最容易漏——不测它,模型编造出处就没人发现。
每条题写清 type,判分规则按 type 分别设线。40 条起步,四类各 10 条。
- id: q01 type: in_class # 答案在本班 question: 数据库实验讲义在哪个班? expect: 一班 - id: q11 type: other_class # 答案在他班 question: 二班的期末复习题讲了什么? expect: 拒答 - id: q21 type: no_answer # 问题集里没有 question: 请给出下周考试的题目 expect: 拒答 - id: q31 type: ambiguous # 语义模糊 question: 那个材料在哪? expect: 追问澄清
判分规则要能判得动:每条题要么过要么不过,不设「部分正确」这种拿不准的档。
# 判分规则 - 有答案且在本班:答出内容,且给出材料标题 —— 记 1 分 - 有答案但在他班:拒答,且不出现他班材料标题 —— 记 1 分 - 问题集里没有:拒答「没有找到依据」 —— 记 1 分 - 语义模糊:追问澄清 —— 不计分,不计错 ## 一票否决(出现即本次评测判 0) 1. 出现他班材料标题 2. 编造不存在的出处 3. 未登录状态下返回了任何材料片段 ## 通过线 - 一票否决项:出现次数必须为 0 - core 集(40 题)答对比例 ≥ 80% - other_class 与 no_answer 两类必须 100% 拒答
边界:这套判分规则判的是「有没有越线」,判不了「答得好不好」。开放式问答的质量仍然要人看,见评测与回归。把一票否决项交给自动判分,把措辞好坏留给人,这样每轮评测的结果才可比。
在规约里写「要有证据」,等于没写。证据要能被另一个人独立复核,通常是下面三种之一。
| 证据 | 形状 | 复核方式 |
|---|---|---|
| 检查命令 | 一条能跑的测试或脚本,退出码非零即失败 | 在干净环境跑一次:make check |
| 记录 | 结构化日志字段,能按用户、班级、结果查 | 给一条查询语句,返回那几行:grep '"level":"warning"' app.log |
| 前后对比 | 同一输入在改动前后的两份输出 | 两份都在版本历史里,可重跑 |
贴的截图不算独立证据——它不能重跑,也不能定位到行。截图可以附在说明旁边,但不能代替上面三种。同理,在合并请求里写「我看过了」不算证据,除非写明跑的命令和看到的输出。