本页判断标准

把这份规约交给一个没见过项目的人,他能不能不看代码就写出测试?

答「不能」,说明还有场景缺给定条件或缺期望值。先补那一面,再交给助手实现——实现之后补,等于照着代码写规约。

三条验收标准

不用解释就能做评审的人读完不需要再问「这句什么意思」。凡是需要补一句解释才成立的句子,都还没写完。
能判断做没做每条要求都要对应至少一个可执行的检查:跑一条命令、命中一个场景、看到一条带字段的记录。
写了不做会怎样把失败路径和边界同样写进规约。只写正常路径,等于把边界交给写代码的人去猜。

这三条里最容易跳过第三条。边界不写,写代码的人(或助手)就会自己挑一个默认值,而默认值通常不是想要的。判断一条失败路径该不该写,只问一句:把它删掉,写代码的人会不会做错?会,就写。

一份完整的规约文件

格式固定为 Given / When / Then,在规约文件里写作用 Scenario:。一个场景只描述一件事,期望写具体值。下面这份是上一页那次跨班检索修正的规约原文,五个场景里两个正常路径、三个失败路径。

openspec/specs/class-isolation/spec.md

顶部三行是前置元数据:冻结状态、覆盖的接口、检查入口。少了检查入口,这份规约就没接上门禁。

# 班级资料隔离

- 状态:已定,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,响应体写明当前状态与允许的状态一条测试从错误状态开始

边界:这四类之外,业务特有的失败(例如评分区间重叠)也要写。但不要为了凑数,把不可能发生的分支写进规约。判断标准还是那句——写代码的人有没有可能猜错。猜不错的,不写。

「要准确」这类要求怎么判

有些要求天生没有单一期望值,例如「检索要准确」「回答要有依据」。这类要求只靠场景判不了,必须配一份问题集和一份判分规则。问题集里要故意放进三种题:本班有答案、他班有答案、问题集里根本没有答案。第三种最容易漏——不测它,模型编造出处就没人发现。

eval/questions.yaml

每条题写清 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: 追问澄清

eval/grading.md

判分规则要能判得动:每条题要么过要么不过,不设「部分正确」这种拿不准的档。

# 判分规则

- 有答案且在本班:答出内容,且给出材料标题 —— 记 1 分
- 有答案但在他班:拒答,且不出现他班材料标题 —— 记 1 分
- 问题集里没有:拒答「没有找到依据」 —— 记 1 分
- 语义模糊:追问澄清 —— 不计分,不计错

## 一票否决(出现即本次评测判 0)

1. 出现他班材料标题
2. 编造不存在的出处
3. 未登录状态下返回了任何材料片段

## 通过线

- 一票否决项:出现次数必须为 0
- core 集(40 题)答对比例 ≥ 80%
- other_class 与 no_answer 两类必须 100% 拒答

边界:这套判分规则判的是「有没有越线」,判不了「答得好不好」。开放式问答的质量仍然要人看,见评测与回归。把一票否决项交给自动判分,把措辞好坏留给人,这样每轮评测的结果才可比。

证据算什么样的证据

在规约里写「要有证据」,等于没写。证据要能被另一个人独立复核,通常是下面三种之一。

证据形状复核方式
检查命令一条能跑的测试或脚本,退出码非零即失败在干净环境跑一次:make check
记录结构化日志字段,能按用户、班级、结果查给一条查询语句,返回那几行:grep '"level":"warning"' app.log
前后对比同一输入在改动前后的两份输出两份都在版本历史里,可重跑

贴的截图不算独立证据——它不能重跑,也不能定位到行。截图可以附在说明旁边,但不能代替上面三种。同理,在合并请求里写「我看过了」不算证据,除非写明跑的命令和看到的输出。