直接回答

AI 编程真正有价值的标志,不是“代码写得快”,而是产出可审查的行为与证据。本文以 QubitTool 的 SBTI 测试为例,展示一条完整流程:

text
探索 -> 提案 -> 审阅 -> 实施 -> 验证 -> 归档

这个功能同时包含动态路由、30 道计分题、条件问题、15 个维度、确定性模板匹配、双语内容、结果 URL、分享海报和浏览器保存差异,比单页面演示更能暴露 Spec Coding 的真实价值。

本文不再声称网站可以在固定“半天”内完成。实际耗时取决于起始代码、验收深度、评审、返工、视觉 QA、无障碍和发布门禁。

核心要点

  • 用 OpenSpec 在写代码前把目标行为变成可审查制品。
  • 把题目、计分和结果模板从 React 视图中分离。
  • 把每个 AI Patch 都视为待验证提案,而不是已完成事实。
  • 计分引擎独立于 UI 和结果文案进行测试。
  • 双语一致性依赖 ID 与行为,而不是句子逐字镜像。
  • 海报需要按图片制品验收;DOM 正确不代表 Canvas 正确。
  • 只有实现与当前变更制品一致且门禁通过,才执行归档。

当前 SBTI 系统包含什么

项目使用 Next.js Pages Router,并由共享测试注册表承载不同测试。SBTI 是其中一个独立实现:

text
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 核心工作流先创建可审阅变更,再进入实现:

bash
npm install -g @fission-ai/openspec@latest
openspec init

在支持 OpenSpec Skill 的 AI 助手中:

text
/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 是最高价值的制品。它应说明用户和系统能观察到什么:

markdown
## Requirement: 测试产生可复现结果

### Scenario: 完整常规答案
- **WHEN** 用户回答全部 30 道计分题
- **THEN** 系统计算且仅计算 15 个维度等级
- **AND** 每个等级只能是 L、M 或 H
- **AND** 匹配器返回有效结果代码

### Scenario: 存在未完成题目
- **WHEN** 任意计分题没有答案
- **THEN** 系统不提交结果
- **AND** 用户返回第一道未完成题目

### Scenario: 中英文提交相同答案
- **WHEN** 两种语言提交相同 Option ID
- **THEN** 维度等级和结果代码必须一致

这些场景没有指定组件名、CSS 类或状态库,因为只要不影响公开行为,它们属于设计或实现选择。

把数据模型写清楚

题目与结果配置都是可执行内容,必须拥有稳定身份:

text
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:

go
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)
}

预期输出:

text
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、页面组件、结果代码和导航身份,避免新增测试退化为复制粘贴分支。

分离状态与展示

可靠的依赖关系如下:

flowchart LR D["版本化测试数据"] --> E["纯计分引擎"] E --> R["结果身份"] R --> V["本地化结果页"] R --> P["分享海报"] Q["路由注册表"] --> V Q --> P T["测试与夹具"] --> E T --> V T --> P

计分引擎不应读取翻译、浏览器尺寸或 Canvas 状态;结果页不应重新计算维度;海报应接收已提交的结果模型,而不是从可见 DOM 反向抓取文本。

把 AI 编程限制为受控执行

OpenSpec 能减少歧义,但不会自动产生正确代码。在 Apply 阶段:

  1. 给 Agent 明确的文件范围。
  2. 让题目与结果数据保持单一事实源。
  3. 先完成聚焦测试,再做大范围视觉调整。
  4. 每跨过一个职责边界就审阅 Diff。
  5. 拒绝无关重构。
  6. 运行仓库真实的 Lint、Build 与浏览器检查。
  7. 接受行为发生变化时,先更新 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 指南进一步理解方法。

参考资料