击键记录
Potato 可以记录一份自由文本答案背后的停顿、爆发、修改和粘贴,而不记录标注者键入的任何字符。
Potato 可以记录一份自由文本答案是怎么写出来的,而不记录这份答案本身。每个事件带有一个时间戳、一个输入类型、一个按键类别和一个长度变化量;没有一项带上被键入的那个字符。Potato 从这份事件流里算出约四十项摘要特征,并把它们和标注一起存下来。
这么做是为了区分两类答案:一类是撰写出来的,带着一个人边想边写时的停顿和反悔;另一类是誊抄的,从另一个窗口重新打一遍,或者从聊天机器人那里粘贴过来。读成品答案,它们看着差不多。日志不是。
击键记录需要 Potato 2.7.2 或更高版本,并且默认关闭:在你设置之前,keystroke_logging.enabled 一直是 false,所以升级不会开始记录任何人。基于这些数据的规则,见写作过程检测。在把它对准人类参与者之前,请先读击键记录伦理。
快速开始
keystroke_logging:
enabled: true这就是全部的最小配置。项目里的每个自由文本字段都会开始产生一份内容无关的事件流、一份摘要和一组检测标记。
Potato 附带一个可运行的示例:
python potato/flask_server.py start examples/advanced/keystroke-logging/config.yaml -p 8000警告:
enabled默认为false。升级 Potato 绝不会悄悄开始记录你的标注者。
采集了什么
每个事件记录一个时间戳、一个输入类型、一个按键类别、光标位置,以及字段长度的变化:
{t_ms: 1240, input_type: "insertText", key_class: "letter", pos: 41, delta: +1}
{t_ms: 1310, input_type: "insertText", key_class: "letter", pos: 42, delta: +1}
{t_ms: 3980, input_type: "deleteContentBackward", key_class: "bksp", pos: 42, delta: -1}
{t_ms: 9120, input_type: "insertFromPaste", key_class: "unknown",pos: 43, delta: +287,
meta: {paste_source: "external", paste_hash: "sekqf3"}}
刻意不采集的内容
| 不采集 | 原因 |
|---|---|
| 键入的字符 | 事件流还原的是过程,不是文本 |
| 粘贴进来的文本 | 只保留长度、来源标签和一个加盐哈希 |
| 中间草稿 | 仅凭长度变化量无法还原 |
| 密码字段中的任何内容 | getFieldIdentity 直接拒绝 type="password" |
| 剪贴板内容(总体上) | 粘贴时读取用于分类,随后丢弃 |
按键类别
按键本身从不存储,只存它属于哪一族:
letter、digit、punct、space、enter、bksp、del、nav、mod、func、unknown
输入类型
Potato 的主要信号是 beforeinput 上的 InputEvent.inputType,而不是 keydown。这是核心的技术选择。粘贴、拖放、IME 组字、听写、自动填充和撤销都会改动字段,却完全不触发 keydown,所以一个只看 keydown 的记录器,恰好对这个功能存在的目的所要检测的那些情况是瞎的。
采集的输入类型:insertText、insertReplacementText、insertFromPaste、insertFromDrop、insertCompositionText、insertLineBreak、insertParagraph、deleteContentBackward、deleteContentForward、deleteWordBackward、deleteWordForward、deleteByCut、deleteByDrag、historyUndo、historyRedo,外加合成的 focus、blur 和 keydown。
keydown 和 keyup 仍然被监听,但只用于统计物理击键和测量按键停留时间。出现的字符数与实际按下的键数之间的差距,是采集到的最强的单一信号。见下文的 silent_insert_ratio。
哪些字段会被记录
默认是每个自由文本字段:text schema、radio 和 multiselect 内部的自由填写框,以及 text_edit、pairwise、trajectory_eval 等 schema 里的理由说明或备注文本域。
字段通过 Potato 本来就打在每个标注输入元素上的 schema 和 label_name 属性来识别,识别不到时退回到按 ::: 切分 name 属性。
用其中任一个列表来限定范围:
keystroke_logging:
enabled: true
include_schemas: [rationale] # allowlist; empty = all fields
exclude_schemas: [scratch_notes] # denylist或者在自定义 HTML 里让单个元素退出:
<textarea data-keystroke-logging="off" ...></textarea>配置参考
keystroke_logging:
enabled: false # master switch
fidelity: events # off | summary | events
include_schemas: [] # empty = every free-text field
exclude_schemas: []
store_events: true # persist raw streams (needs fidelity: events)
classify_paste_source: true # label pastes self/instance_text/ai_suggestion/external
idle_session_ms: 30000 # close a session after this much inactivity
flush_interval_ms: 5000 # how often the browser posts completed sessions
pause_thresholds_ms: [500, 1000, 2000, 5000, 10000]
disclose_to_annotators: true # show a recording notice
detection:
enabled: true
calibrate: false # use project-fitted thresholds
on_external_insert: flag # allow | warn | block | flag
thresholds: {} # per-rule overrides| 键 | 默认值 | 含义 |
|---|---|---|
enabled | false | 总开关。为 false 时什么都不采集。 |
fidelity | events | off 表示停用;summary 计算特征但不存事件流;events 两者都存。 |
include_schemas | [] | schema 名称白名单。留空表示全部。 |
exclude_schemas | [] | 黑名单,在白名单之后应用。 |
store_events | true | 持久化原始事件流。除非 fidelity: events,否则忽略。 |
classify_paste_source | true | 把粘贴内容与正在标注的材料、AI 建议以及字段自身内容做比对。 |
idle_session_ms | 30000 | 空闲多久之后关闭并提交一个会话。 |
flush_interval_ms | 5000 | 浏览器提交的节奏。 |
pause_thresholds_ms | [500,1000,2000,5000,10000] | 在每个阈值上分别统计停顿次数。 |
disclose_to_annotators | true | 显示录制提示。关闭它会记录一条警告。 |
检测相关的键在写作过程检测中说明。
选择 fidelity
| fidelity | 是否存事件流 | 之后能重算新指标吗? | 适用场景 |
|---|---|---|---|
off | — | — | 该项目停用此功能 |
summary | 否 | 否 | 你确定自己需要哪些特征,或者你的伦理审批不覆盖保留事件流 |
events | 是 | 是 | 默认。每次击键约 2 字节 |
推荐用 events。一份 500 词的回答约占 5 KB,而且这意味着数据收集结束之后你才想到的指标仍然算得出来。
摘要特征
每个(用户、实例、字段)组合一份摘要。特征族沿用 Crossley 等人(2024);见研究依据。
体量与产品-过程比
| 字段 | 含义 |
|---|---|
keystrokes | 产生了文本的物理 keydown 次数 |
final_chars | 会话结束时的字段长度 |
chars_typed / chars_inserted | 通过打字插入的字符数 / 通过任何方式插入的字符数 |
chars_deleted | 被删除的字符数 |
chars_per_keystroke | 高于约 1.1 意味着有文本在没有击键的情况下出现 |
active_ms / wall_ms | 在该字段上的时间,不含 / 含离开的时间 |
节奏
| 字段 | 含义 |
|---|---|
iki_median_ms、iki_mean_ms | 按键间隔的集中趋势 |
iki_p10/p25/p75/p90_ms | 间隔分布的形状 |
iki_log_sd、iki_log_cv | 对数尺度上的离散度。低意味着节拍均匀,也就意味着誊抄。 |
用对数尺度是因为按键间隔的分布严重右偏。超过 30 秒的间隔被排除在这些统计之外,免得一次喝咖啡的休息就主导了它们。
停顿
| 字段 | 含义 |
|---|---|
pause_counts | 每个配置阈值上的次数 |
pause_total_ms | 停顿的总时长 |
pre_word_pause_mean_ms | 开始写一个词之前的平均停顿 |
pre_sentence_pause_mean_ms | 标点之后的平均停顿 |
intraword_iki_median_ms | 词内间隔的中位数,可作为键盘熟练度的代理指标 |
爆发
| 字段 | 含义 |
|---|---|
bursts、burst_mean_chars、burst_max_chars | 连续产出的统计 |
p_bursts | 由停顿终止的爆发 |
r_bursts | 由修改终止的爆发 |
修改
| 字段 | 含义 |
|---|---|
backspaces、deletes、undo_events | 删除行为 |
non_terminal_edits | 在文本末尾之后方发生的编辑,意味着写作者回头做了修改 |
caret_jumps | 非相邻的光标移动 |
revision_ratio | chars_deleted / chars_typed |
外部插入
| 字段 | 含义 |
|---|---|
paste_events、pasted_chars、largest_paste_chars | 粘贴的量 |
pasted_fraction | 最终文本中来自粘贴的比例 |
drop_events | 拖放插入 |
silent_insert_chars / silent_insert_ratio | 没有对应击键的字符 |
external_insert_chars / external_insert_ratio | 同上,但排除自引和对材料的引用 |
paste_sources、paste_chars_by_source | 按来源标签统计的次数和字符数 |
检测请用 external_insert_ratio。silent_insert_ratio 统计的是全部无声插入,包括正当的那些。
注意力
| 字段 | 含义 |
|---|---|
blur_events、blur_total_ms | 离开页面的时间 |
max_blur_before_insert_ms | 一次大段插入之前紧邻的最长离开时间 |
first_keystroke_latency_ms | 打出第一个字符之前的思考时间 |
完整性
| 字段 | 含义 |
|---|---|
untrusted_events | InputEvent.isTrusted === false,意味着脚本或自动化输入 |
composition_events | IME 组字 |
virtual_keyboard | 检测到移动端或软键盘 |
数据存在哪里
两个去处,出于两种不同的理由。
原始事件流进 SQLite
<task_dir>/project.sqlite,表 typing_sessions,每个会话一行,走的是和备忘、编码手册相同的持久化层。
可查询的摘要列以反范式的方式与完整 JSON 摘要和一个 zlib 压缩的事件 blob 并排存放:
sqlite3 <task_dir>/project.sqlite "
SELECT user_id, schema_name, keystrokes, final_chars,
pasted_fraction, silent_insert_ratio, iki_log_cv,
json_extract(flags,'\$.level') AS level
FROM typing_sessions;"事件流按会话存成一个压缩的 blob,而不是每次击键一行。它只会被整体读回,而且以每个事件约 2 字节计,一行一击键的表结构会让项目文件里堆进几千万行,查询上却毫无好处。
阶段页面
培训阶段以及前置或后置问卷里的自由文本答案也会被采集。那些页面没有实例 id,所以它们的会话归到行为系统本来就在用的 __phase_page__ 哨兵值下,改用 phase 和 page 两列来标识:
SELECT phase, page, count(*) FROM typing_sessions GROUP BY phase, page;这正是校准示例能奏效的原因。培训阶段里一个誊抄材料的任务会产出誊抄样本,仅凭 phase 就能把它们和普通的撰写答案分开。
摘要进 user_state.json
紧凑的概要被镜像到 <output_annotation_dir>/<user>/user_state.json 中的 instance_id_to_behavioral_data.<instance>.typing_summaries 下,键为 "{schema}:::{label}",这样它就能随标注一起进入管理员仪表板和导出。
原始事件流刻意不放在那里。那个文件在每次保存标注时都会被完整重新序列化并原子重写,而一份长回答有数千个事件。
导出
两种导出都需要主动开启,所以行为数据不会不小心进入数据集发布。
摘要特征与标注并列导出
export_include_typing_dynamics: true在 annotations.csv 旁边生成 typing_dynamics.csv(或 .tsv),每个(用户、实例、字段)组合一行,含摘要特征和检测器的判定结果。
原始事件流
python -m potato.export.cli <config.yaml> --format keystrokes写出 keystroke_sessions.parquet 和 keystroke_events.parquet,未安装 pyarrow 时回退到 JSONL。更大范围的导出器见 Parquet 导出。
import pandas as pd
events = pd.read_parquet("keystroke_events.parquet")
# Inter-key intervals for one session
s = events[events.session_id == events.session_id.iloc[0]].sort_values("t_ms")
iki = s.t_ms.diff().dropna()
print(iki.median(), iki.std())
# Every externally-sourced paste in the project
print(events[events.paste_source == "external"])API 端点
| 方法 | 路由 | 用途 |
|---|---|---|
POST | /api/track_typing | 接收浏览器提交的已完成会话 |
GET | /api/typing_summary/<instance_id> | 当前用户在某个实例上的摘要 |
GET | /admin/api/writing_process | 按标注者汇总(需要管理员密钥) |
会话在服务端汇总。浏览器从不发送算好的摘要,所以这些数字无法通过改造过的客户端伪造,而且之后新增的指标可以从已存的事件流重新算出来。
会话是怎么划分的
一个会话在字段获得焦点时开始,并在下列条件中最先发生的那个上结束:失去焦点、导航到另一个实例、空闲达到 idle_session_ms,或页面卸载。已完成的会话每隔 flush_interval_ms 提交一次,页面卸载时通过 navigator.sendBeacon 提交,这样进行中的会话不会丢。
同一字段上的多个会话在写入用户状态之前会先合并,所以离开字段再回来读作一次回答,而不是好几次短得可疑的回答。计数和时长相加。分布统计量是按击键数加权的近似值,因此如果你需要精确的合并分布,请用原始事件流。
故障排除
没有记录到任何数据
检查 keystroke_logging.enabled: true,以及 fidelity 不是 off。在浏览器控制台里,window.keystrokeTracker 应该存在且 isInitialized === true。如果它是 undefined,说明配置从未到达模板。
追踪器在,但没有会话出现
检查字段识别:
const el = document.querySelector('textarea');
window.keystrokeTracker.getFieldIdentity(el); // null means it is not trackednull 表示该元素既没有 schema 或 label_name 属性,也没有用 ::: 分隔的 name,或者它被配置排除了。
silent_insertion 把每个移动端标注者都标记了
不应该这样,因为 virtual_keyboard 为 true 时这条规则会被抑制。如果检测误触发,请检查客户端是否设置了那个标志。见误报表。
project.sqlite 在变大
每次击键约 2 字节。设成 fidelity: summary 可以保留特征、丢掉事件流,或者用 typing_store.delete_for_user() 删除某一位参与者的数据。
自动化测试的数字看起来不对
浏览器自动化以接近零的间隔打字,这确实会触发 implausible_speed。那是标记在正常工作,不是 bug。
延伸阅读
- 写作过程检测 - 六条规则和三层检测
- 击键记录伦理 - 知情同意、IRB、留存、参与者权利
- 行为追踪 - 这个功能所处的更大的交互追踪系统
- 质量控制 - 注意力检查和金标准
- 管理员仪表板 - 写作过程面板所在之处
有关实现细节,请参阅源代码文档。