直接回答
AI 编程真正有价值的标志,不是“代码写得快”,而是产出可审查的行为与证据。本文以 QubitTool 的 SBTI 测试为例,展示一条完整流程:
探索 -> 提案 -> 审阅 -> 实施 -> 验证 -> 归档
这个功能同时包含动态路由、30 道计分题、条件问题、15 个维度、确定性模板匹配、双语内容、结果 URL、分享海报和浏览器保存差异,比单页面演示更能暴露 Spec Coding 的真实价值。
本文不再声称网站可以在固定“半天”内完成。实际耗时取决于起始代码、验收深度、评审、返工、视觉 QA、无障碍和发布门禁。
核心要点
- 用 OpenSpec 在写代码前把目标行为变成可审查制品。
- 把题目、计分和结果模板从 React 视图中分离。
- 把每个 AI Patch 都视为待验证提案,而不是已完成事实。
- 计分引擎独立于 UI 和结果文案进行测试。
- 双语一致性依赖 ID 与行为,而不是句子逐字镜像。
- 海报需要按图片制品验收;DOM 正确不代表 Canvas 正确。
- 只有实现与当前变更制品一致且门禁通过,才执行归档。
当前 SBTI 系统包含什么
项目使用 Next.js Pages Router,并由共享测试注册表承载不同测试。SBTI 是其中一个独立实现:
pages/quiz/[slug]/
index.tsx
test.tsx
result/[code].tsx
types.tsx
about.tsx
src/quiz/
quiz-definitions.ts
sbti/
data/questions.js
data/personalities.js
engine/scoring.js
engine/matching.js
components/
pages/
styles/
这个结构把通用路由与 SBTI 专属数据、计算、页面和样式分开。它来自当前仓库实现,但不是所有测试应用都必须照搬的模板。
从 Change 开始,而不是从一句 Prompt 开始
当前 OpenSpec 核心工作流先创建可审阅变更,再进入实现:
npm install -g @fission-ai/openspec@latest
openspec init
在支持 OpenSpec Skill 的 AI 助手中:
/opsx:explore
/opsx:propose add-sbti-quiz
/opsx:apply add-sbti-quiz
/opsx:archive add-sbti-quiz
不同宿主的命令拼写可能不同,当前 OpenSpec 文档还要求 Node.js 20.19 或更高版本。使用前应核对已安装版本和生成的宿主说明,不能直接复制旧文章中的命令。
核心制品分别保存不同决策:
| 制品 | 应保存的决策 |
|---|---|
proposal.md |
为什么做、范围、非目标与影响 |
specs/<capability>/spec.md |
可观察行为与场景 |
design.md |
架构、数据归属、权衡与迁移 |
tasks.md |
实施与验证工作 |
当当前 Schema 允许且变更足够简单时,可以省略 design.md。不要为了填满模板制造架构仪式。
先写行为规格
Spec 是最高价值的制品。它应说明用户和系统能观察到什么:
## Requirement: 测试产生可复现结果
### Scenario: 完整常规答案
- **WHEN** 用户回答全部 30 道计分题
- **THEN** 系统计算且仅计算 15 个维度等级
- **AND** 每个等级只能是 L、M 或 H
- **AND** 匹配器返回有效结果代码
### Scenario: 存在未完成题目
- **WHEN** 任意计分题没有答案
- **THEN** 系统不提交结果
- **AND** 用户返回第一道未完成题目
### Scenario: 中英文提交相同答案
- **WHEN** 两种语言提交相同 Option ID
- **THEN** 维度等级和结果代码必须一致
这些场景没有指定组件名、CSS 类或状态库,因为只要不影响公开行为,它们属于设计或实现选择。
把数据模型写清楚
题目与结果配置都是可执行内容,必须拥有稳定身份:
Question
id
dimension
options[] -> stable value
Dimension
id
group
localized name
grade thresholds
ResultTemplate
code
15-grade pattern
localized content
related result codes
当前 SBTI 有 30 道计分题,每个维度两道;另有一道固定分支题和一道条件追问。常规匹配器包含 25 个模板,兜底结果和条件结果组成另外两个展示类型。
i18n 文件可以本地化题目、标签和解释,但不能改变题目 ID、选项值、维度顺序、结果代码或路由身份。
明确定义计分契约
每个维度的两个答案取值均为 1 到 3,再进行求和:
| 总分 | 分档 |
|---|---|
| 2-3 | L |
| 4 | M |
| 5-6 | H |
15 个等级转换成 0、1、2,并与每个常规模板计算曼哈顿距离。当前实现中,第一个最小距离模板胜出,因此模板顺序会影响并列行为,必须进入测试。
可运行的 Go 校验器
下面的程序验证分档边界,并生成当前 CTRL 模板对应的 Profile:
package main
import (
"errors"
"fmt"
"strings"
)
func gradePair(left, right int) (byte, error) {
if left < 1 || left > 3 || right < 1 || right > 3 {
return 0, errors.New("answers must be between 1 and 3")
}
switch sum := left + right; {
case sum <= 3:
return 'L', nil
case sum == 4:
return 'M', nil
default:
return 'H', nil
}
}
func buildProfile(answers [][2]int) (string, error) {
if len(answers) != 15 {
return "", errors.New("exactly 15 answer pairs are required")
}
grades := make([]byte, 0, 15)
for _, pair := range answers {
grade, err := gradePair(pair[0], pair[1])
if err != nil {
return "", err
}
grades = append(grades, grade)
}
groups := []string{
string(grades[0:3]),
string(grades[3:6]),
string(grades[6:9]),
string(grades[9:12]),
string(grades[12:15]),
}
return strings.Join(groups, "-"), nil
}
func main() {
high := [2]int{3, 3}
mid := [2]int{2, 2}
answers := [][2]int{
high, high, high,
high, mid, high,
mid, high, high,
high, high, high,
mid, high, mid,
}
profile, err := buildProfile(answers)
if err != nil {
panic(err)
}
fmt.Println(profile)
}
预期输出:
HHH-HMH-MHH-HHH-MHM
这个 Oracle 只能验证当前计算契约,不能验证题目和人格标签的心理学意义。
设计清晰的路由边界
共享路由应先解析测试定义,再渲染页面。未知 Slug 与 Result Code 必须有显式行为。
| 路由 | 契约 |
|---|---|
/quiz/[slug] |
已注册测试的介绍页 |
/quiz/[slug]/test |
同一测试定义的答题流程 |
/quiz/[slug]/result/[code] |
有效结果或受控 Not Found |
/quiz/[slug]/types |
已注册结果类型图鉴 |
/quiz/[slug]/about |
原理与限制 |
不要在每个页面重复写测试元数据。注册表负责 Slug、Namespace、页面组件、结果代码和导航身份,避免新增测试退化为复制粘贴分支。
分离状态与展示
可靠的依赖关系如下:
计分引擎不应读取翻译、浏览器尺寸或 Canvas 状态;结果页不应重新计算维度;海报应接收已提交的结果模型,而不是从可见 DOM 反向抓取文本。
把 AI 编程限制为受控执行
OpenSpec 能减少歧义,但不会自动产生正确代码。在 Apply 阶段:
- 给 Agent 明确的文件范围。
- 让题目与结果数据保持单一事实源。
- 先完成聚焦测试,再做大范围视觉调整。
- 每跨过一个职责边界就审阅 Diff。
- 拒绝无关重构。
- 运行仓库真实的 Lint、Build 与浏览器检查。
- 接受行为发生变化时,先更新 Spec。
Agent 把所有任务勾选完成,只代表它报告了自己的进度。验收证据必须来自测试、渲染结果和独立审阅。
海报生成是独立产品面
DOM 转 Canvas 库不会像浏览器渲染器一样支持全部 CSS。应定义受约束的海报契约:
- 固定截图宽度与宽高行为;
- 截图前字体和本地图片已加载;
- 明确支持的颜色格式与特效;
- 确定性 Device Pixel Ratio 策略;
- 不包含不支持的 Filter 或外部资源;
- 明确超时和失败界面;
- 普通浏览器采用下载路径;
- 阻止程序下载的浏览器提供长按保存提示。
必须检查生成的位图,而不是只看组件。覆盖长名称、窄屏、高 DPR 和不同图片类型;Promise 成功返回不等于像素正确。
验证矩阵
| 层级 | 必须提供的证据 |
|---|---|
| 数据 | ID 唯一、维度有效、选项范围正确、关联 Result Code 存在 |
| 计分 | 阈值边界、精确模板、并列、兜底、条件分支 |
| 路由 | 每个注册页面与结果 URL 可访问 |
| i18n | 等价答案在两种语言得到相同身份 |
| UI | 键盘路径、焦点、标签可读、无布局溢出 |
| 海报 | 图片非空、尺寸正确、资产加载、保存回退 |
| 隐私 | 本地计分已验证;Analytics 与主动提交单独说明 |
| 发布 | Lint、Build、聚焦测试、浏览器截图与回滚路径 |
当前项目由 quiz-definitions.ts 统一注册路由与 SEO;SBTI 在 src/quiz/sbti/ 内拥有题目、模板、计分、匹配、组件和样式。
原案例中不应保留的说法
以下说法吸引眼球,却不是可靠工程证据:
| 说法 | 问题 | 更好的记录 |
|---|---|---|
| “半天做完” | 范围和验收深度未知 | 带评审与返工的任务时间线 |
| “AI 完美翻译全部文案” | 没有双语验收证据 | ID 对齐加语言审阅 |
| “Agent 解决了 Canvas Bug” | 缺少失败与回归范围 | 失败夹具、Patch 和截图检查 |
| “独立路由保证传播” | 路由结构不能证明分发效果 | 分享预览、Canonical、点击与留存 |
| “多提交就安全” | Commit 数量不能证明质量 | 可回退且门禁通过的逻辑检查点 |
真正可复用的经验不是速度,而是显式行为契约让人和 Agent 更早发现分歧、围绕同一结果验证,并在新会话中继续执行而不依赖聊天记忆。
常见问题
每个功能都应该使用 OpenSpec 吗?
不必。很小且确定性的修改不一定值得创建完整变更;当行为跨越组件、数据、迁移、安全、兼容性或多个会话与评审者时,OpenSpec 更有价值。
Spec 可以包含实现细节吗?
只有对外可观察或必须保留的技术决策才应进入 Spec。可观察行为写入 Specs,架构取舍写入 Design,普通文件级步骤留在 Tasks。
如何防止计分与翻译漂移?
把稳定 ID 和数值作为共同身份,并用同一 Schema 校验双语文件。可以翻译文本,但不能翻译或重排维度、选项权重、结果代码和路由。
视觉细节应该等 Spec 完成后再做吗?
影响可读性、交互、无障碍或分享结果的视觉行为应进入 Spec。细节样式可以在 Apply 阶段迭代,但验收视口与输出要求必须明确。
什么时候可以归档 Change?
所有必要行为已实现、任务已解决、自动检查通过、浏览器证据覆盖高风险路径,并且当前实现与最新制品一致时,才进入 Archive。
总结
SBTI 案例说明了 Spec Coding 真正应该沉淀什么:行为、归属、失败语义和验收证据。OpenSpec 建立审阅循环,而工程质量来自类型化数据模型、纯计分契约、双语一致性、图片级海报检查,以及不会随模型或编程工具变化而失效的发布门禁。
你可以查看最终的 SBTI 测试,并通过 OpenSpec 教程和 Spec Coding 指南进一步理解方法。