从使用到体验
一份报告只是给出了症状。真正需要处理的是症状背后的体验。「对话转向正题时客户消失了」是一个 Bug。「一次对话会聚集其涉及的内容:当正题被添加时,客户会留在对话中」才是要构建、验证和录制的目标。先写出这句话。然后修复允许症状存在的结构,再通过使用产品来证明这句话。
循环
- 收集使用情况。 阅读报告,然后阅读真实使用的痕迹:对方的对话、日志、记录、状态、他们接触过的文件。这些痕迹里蕴含着没人写下来的期望(「他们进展到哪了?」期望的是具体的要点本身,而不是一个摘要)。
- 写下体验。 每个报告对应一个句子,使用现在时,从对方的角度出发,不包含任何技术实现词汇。保留这个列表。它会变成影片的旁白和验证点(claims)的消息。
- 找到结构性原因。 询问为什么产品会允许这种事情发生。一个原因通常能解释多个报告(例如,身份从显示名提取;两个计数使用了同一个词;渲染器在插入时丢弃了一个类)。修复原因,而不是修复显示那个问题的界面。
- 亲身体验。 通过浏览器命名空间(如下所示),以每个相关角色的身份,在桌面端和手机端宽度下操作真实的应用程序。查看截图。统计页面实际显示的内容,而不是代码的意图。
- 保存它。 将手动步骤提升为一个场景:动作变成事件,检查变成断言(claims),体验句子变成该场景的旁白。将模型生成的回合录制在磁带上,这样影片可以在几秒钟内以零成本重放。
- 报告体验,而非补丁。 在摘要中,首先说明用户将会看到什么,然后是原因,接着是影片以及如何重放它。
团队风格(Othman 反复提出的要求)
- 更少的文案。 状态应该通过绘图来呈现:色调、粗细、位置、一个圆环、一个标记。如果需要一个句子来解释某个界面,说明这个界面本身有问题。
- 一个概念用一个词。 维护一个词汇表并坚持使用它:一个「标准」(与流程对比评判)永远不能被称为一个「要点」(由人决定的东西)。如果两个计数共用一个词,人们就会把它们加起来。
- 事物是累积的。 标签是累加的(先是种类,然后是客户,然后是事项)。当前选中的标签是实心的,其他的保持为轮廓。永远不要替换用户已经看到的上下文。
- 数字优先于文字。 历史记录打开时首先显示图表,每个系列一个,并带有数值标签。文本放在下面,每个事件一行,按需展开。
- 每个引用都指向其所在位置。 一个段落编号会滚动到该段落并将其高亮。一个引用会找到其对应的原文。滚动条用相应的颜色标记出内容所在的位置。
- 默认保持实时。 没有完全重载。一个人做出的更改会立即显示在所有打开的页面上。在一个慢速页面上重复点击不能重复效果:操作必须是幂等的。
- 控件出现在其所控制对象的旁边, 并且只对有权使用它们的人显示。只读用户只看到状态,看不到控件。
- 知道谁在那里。 身份和角色信息应触达每个界面和每个 Agent。不要告诉一个合作伙伴「合作伙伴必须确认」。
- 诚实的边界。 清晰地说明产品尚未实现什么。永远不要指向一个不能完成该任务的界面。
- 手机端宽度是首要的阅读器: 16px 内边距,无水平滚动(使用
minmax(0,1fr)而非1fr),并且账户信息始终可访问。 - 使用产品自身的语言制作线框图和旁白(对于 Knowledger 项目,使用法语)。
使用浏览器命名空间进行手动测试
Beam Lisp 的原生浏览器(位于 beam-lisp 代码树中的 optional/browser-port)在私有的无头 Sway 窗口中运行有界面的 Chromium,支持可信输入和持续屏幕录制。它和场景驱动的对象是同一个,因此手动会话可以直接演变为测试,无需重写。对于快速查看,使用 agent-browser。对于任何值得保留的测试,使用浏览器命名空间。
每台机器只需执行一次
cd ~/projects/beam-lisp/optional/browser-port
nix develop --command cargo build --release --locked --features native-browser
nix build .#runtime-wayland --out-link ~/.local/share/bl-browser/runtime-wayland # chromum, sway, wayvnc
command -v ffmpeg || nix profile add nixpkgs#ffmpeg
使用 bl run -p ~/projects/beam-lisp/optional/browser-port/bl …(或 bl test -p …)来运行任何使用了它的代码。
探针(probe):一个文件,一个人,真实输入
templates/probe.bl 是起点。其核心代码如下:
(ns probe (:require [browser.page :as page]))
(def rt (str (System/get_env "HOME") "/.local/share/bl-browser/runtime-wayland/bin/"))
(def p (page/open! {:executable (str (System/get_env "HOME") "/projects/beam-lisp/optional/browser-port/target/release/bl-browser-port")
:width 1440 :height 860
:launch {"profile" "/tmp/probe/profile" "runtime" "/tmp/probe/runtime"
"chromium" (str rt "chromium") "compositor" (str rt "sway") "vnc" (str rt "wayvnc")
"width" 1440 "height" 960}}))
(try
;; who is looking: the identity the proxy would add (here Tailscale headers); it reaches the live socket too
(page/cdp! p "Network.enable" {})
(page/cdp! p "Network.setExtraHTTPHeaders" {"headers" {"tailscale-user-login" "[email protected]" "tailscale-user-name" "timinou"}})
(page/record! p "/tmp/probe/take.mp4" {:fps 24}) ; optional: the whole session as a movie
(page/navigate! p "http://127.0.0.1:24025/a/sahel-…")
(page/wait! p "document.documentElement.dataset.live==='1'") ; a JS predicate, 30 s bound by default
(page/click! p ".rv-rail a.para[data-goto]") ; trusted pointer: moves, presses, releases
(println (page/evaluate! p "scrollY")) ; ask the page what a person would see
(page/screenshot! p "/tmp/probe/after.png") ; then LOOK at it
(page/stop-recording! p)
(finally (page/close! p)))
可用的动词是 navigate! wait! click! hover! fill! key! drag! evaluate! screenshot! record! stop-recording! show-cursor! cdp!。cdp! 可以调用 Chromium 提供的任何其他功能:视口、网络、Cookie、仿真(Emulation.setDeviceMetricsOverride 用于模拟手机)。
使探针值得保留的习惯
- 询问页面,而非代码。 每个检查都是一个 JavaScript 表达式,当用户能看到该事物时返回
true:行数、标签的文本、scrollY、指针下元素的类名。这些表达式可以原封不动地变为断言。 - 按含义选择。 在测试需要的地方,给应用提供稳定的数据属性(例如
button.cell[data-person='Karim Saadi'][data-team='Associés']),永远不要使用「第五个按钮」。 - 扮演每一个人。 为每个人打开一个页面,带上他们的身份。将两个页面并排放置,这是证明「实时」的方式:一个页面有操作,另一个页面无刷新地随之变化。
- 检查首次渲染和补丁更新后的页面。 实时应用先渲染一次 HTML,然后通过数据补丁更新。Bug 常隐藏在这两者的差异中(例如,插入时丢失的类,以标签名读取的列表开头的文本)。通过 URL 访问每个页面一次,然后再通过导航到达它一次。
- 等待页面变为实时状态,而不仅仅是连接完成。 实时客户端可能会用其 socket 的首次渲染替换服务器渲染的页面。在这之间键入的文本会丢失,这对于测试和对于加载后快速键入的用户来说都是如此。应用程序应该保留跨挂载的输入内容,然后声明自己已就绪(
data-mounted)。测试应等待这个标志。 - 检查手机端宽度。 在 390px 宽度下检查
document.documentElement.scrollWidth === innerWidth,然后查看截图。 - 永远不要触及真实数据,并证明这一点。 将应用指向其数据的 reflink 副本(
cp -a --reflink=auto)和一个备用端口。然后在使用它之前证明数据隔离:在副本中重命名一个东西(一个金丝雀测试项,例如将一个团队名改为「AssociésX」),对其操作,然后检查副本已更改而真实文件的时间戳未变。对于每种写入进程(页面处理器、实时 socket、后台任务),都做一次这个检查。在 Beam Lisp 中,从测试中使用的System/put_env能到达页面处理器,但无法影响实时 socket:一个读取未配置数据文件夹的 socket 会回退到默认路径,即真实数据。应用必须在服务器启动时将其配置锁定到整个 VM(使用persistent_term)并从此处读取。如果必须触及实时数据,请为文件创建快照、操作、通过 diff 确认只有你的更改,然后恢复。
从探针到影片
一个 scenario.demo/defdemo 就是一个普通的测试(bl test)。步骤映射关系如下:
| 探针(Probe) | 场景(Scenario) |
|---|---|
(page/navigate! p url) | (s/does :othman [:navigate url]) |
(page/wait! p js) | (s/does :othman [:wait js]) |
(page/click! p sel) / hover! / fill! / key! | [:click sel] [:hover sel] [:fill sel text] [:key "Enter"] |
| 留给人眼观看的暂停 | [:pause ms] |
必须为真的 (page/evaluate! p js) | (s/sees :othman (fn [p _] (= true (page/evaluate! p js))) "the experience") |
| 一个人物 | browser.film/owned-worlds 中的一个 actor,包裹了用于设置其身份的代码 |
| 一段旁白 | (demo/scene :id {:title … :say … :capture :video} events…) |
templates/feature-demo.bl 是一个完整的文件:应用数据的隔离副本、在测试 VM 的备用端口上启动的应用、带有身份的录制世界、当没有语音键时的静音轨道,以及一个包含三个场景的演示。工作示例:knowledger-interface-review/apps/practice/test/features-demo.bl(五个影片:审查、历史、片段、两人之间实时变更的团队、一段录制的对话)。
将场景写成体验,而不是修复:「每个要点都通向其正确的位置」,而不是「修复 §-链接处理器」。旁白就是文档。
模型决策在磁带上的记录
Agent 所做的任何事都应该经过应用自身的工具门,这样只有模型的决策才被认为是模型的。一个磁带(tape)记录一次模型运行的决策:它发出的工具调用序列及其最终答案,由模型、系统提示和提示(经过掩码处理时间戳、纪元编号和十六进制 ID)的哈希值索引。
record:重放磁带上已有的内容,对于磁带没有的部分则运行模型,并保存结果。replay(默认):不在磁带上的运行是一个错误回合。重放永远不会静默地回退到模型。- 重放通过同一个工具门发出相同的调用,并且带节奏控制,因此产品会再次执行这些工作,界面会显示出来,这一切在几秒钟内免费完成。
锁定所有会进入提示的内容(如公司名称、日期等),否则磁带键会漂移。提交磁带文件。参考实现:apps/practice/src/practice/cassette.bl 及其在 conversation.bl 中的两个钩子(turn 和 door)。
运行和渲染
bl test -p ~/projects/beam-lisp/optional/browser-port/bl app/test/features-demo.bl # 重放,验证,捕获
DEMO_RECORD=1 bl test -p … # 将缺失的模型决策录制到磁带(需要模型凭证)
BL_DEMO_EXPORT=1 bl test -p … # 同时渲染 MP4 + 字幕 + 每个影片的章节
每个影片在其固定装置(fixture)中清除上一次的录制,因为录制永远不会覆盖已有的录制。保持每段旁白在两行字幕以内(大约 130 个字符),否则渲染器会拒绝。负载过重的机器可能会使屏幕录制编码器资源不足:请检查 frames/recordings.json,其中 encodedFrames 应约为 秒数 × fps,如果某段录制过短,请重新运行。
渲染(scenario.film/export!)需要 Python 3 和带有 libass 的 FFmpeg。当设置了 DEEPGRAM_API_KEY 时,旁白会被朗读出来。否则,每个场景获得一个长度与其字幕阅读时间相匹配的静音轨道。永远不要假设语音键存在。在本地环回地址上使用 (scenario.demo/serve! dir {:port 4163}) 浏览影片,或使用 work serve 发布。
在说完成之前
- 每个报告项都包含其体验句子、原因以及在真实浏览器中证明它的断言(claim),并且是以每个相关人的视角。
- 桌面端和手机端宽度都已查看,通过截图,而不仅仅是断言。
- 对于每个触及的界面,首次渲染和补丁更新后的页面都已检查。
- 实时行为通过两个人同时操作证明(在相关的地方)。
- 测试过程中没有修改真实数据。任何被触及的数据都已恢复,并显示了 diff 记录。
- 影片从磁带重放,无需网络连接。磁带和场景文件已提交,渲染产物不提交。
- 摘要以用户现在将会看到什么开头。