Skip to content

击键记录

Potato 可以记录一份自由文本答案背后的停顿、爆发、修改和粘贴,而不记录标注者键入的任何字符。

Potato 可以记录一份自由文本答案是怎么写出来的,而不记录这份答案本身。每个事件带有一个时间戳、一个输入类型、一个按键类别和一个长度变化量;没有一项带上被键入的那个字符。Potato 从这份事件流里算出约四十项摘要特征,并把它们和标注一起存下来。

这么做是为了区分两类答案:一类是撰写出来的,带着一个人边想边写时的停顿和反悔;另一类是誊抄的,从另一个窗口重新打一遍,或者从聊天机器人那里粘贴过来。读成品答案,它们看着差不多。日志不是。

击键记录需要 Potato 2.7.2 或更高版本,并且默认关闭:在你设置之前,keystroke_logging.enabled 一直是 false,所以升级不会开始记录任何人。基于这些数据的规则,见写作过程检测。在把它对准人类参与者之前,请先读击键记录伦理

快速开始

yaml
keystroke_logging:
  enabled: true

这就是全部的最小配置。项目里的每个自由文本字段都会开始产生一份内容无关的事件流、一份摘要和一组检测标记。

Potato 附带一个可运行的示例:

bash
python potato/flask_server.py start examples/advanced/keystroke-logging/config.yaml -p 8000

警告: enabled 默认为 false。升级 Potato 绝不会悄悄开始记录你的标注者。

采集了什么

每个事件记录一个时间戳、一个输入类型、一个按键类别、光标位置,以及字段长度的变化:

text
{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"
剪贴板内容(总体上)粘贴时读取用于分类,随后丢弃

按键类别

按键本身从不存储,只存它属于哪一族:

letterdigitpunctspaceenterbkspdelnavmodfuncunknown

输入类型

Potato 的主要信号是 beforeinput 上的 InputEvent.inputType,而不是 keydown。这是核心的技术选择。粘贴、拖放、IME 组字、听写、自动填充和撤销都会改动字段,却完全不触发 keydown,所以一个只看 keydown 的记录器,恰好对这个功能存在的目的所要检测的那些情况是瞎的。

采集的输入类型:insertTextinsertReplacementTextinsertFromPasteinsertFromDropinsertCompositionTextinsertLineBreakinsertParagraphdeleteContentBackwarddeleteContentForwarddeleteWordBackwarddeleteWordForwarddeleteByCutdeleteByDraghistoryUndohistoryRedo,外加合成的 focusblurkeydown

keydownkeyup 仍然被监听,但只用于统计物理击键和测量按键停留时间。出现的字符数与实际按下的键数之间的差距,是采集到的最强的单一信号。见下文的 silent_insert_ratio

哪些字段会被记录

默认是每个自由文本字段:text schema、radiomultiselect 内部的自由填写框,以及 text_editpairwisetrajectory_eval 等 schema 里的理由说明或备注文本域。

字段通过 Potato 本来就打在每个标注输入元素上的 schemalabel_name 属性来识别,识别不到时退回到按 ::: 切分 name 属性。

用其中任一个列表来限定范围:

yaml
keystroke_logging:
  enabled: true
  include_schemas: [rationale]      # allowlist; empty = all fields
  exclude_schemas: [scratch_notes]  # denylist

或者在自定义 HTML 里让单个元素退出:

html
<textarea data-keystroke-logging="off" ...></textarea>

配置参考

yaml
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
默认值含义
enabledfalse总开关。为 false 时什么都不采集。
fidelityeventsoff 表示停用;summary 计算特征但不存事件流;events 两者都存。
include_schemas[]schema 名称白名单。留空表示全部。
exclude_schemas[]黑名单,在白名单之后应用。
store_eventstrue持久化原始事件流。除非 fidelity: events,否则忽略。
classify_paste_sourcetrue把粘贴内容与正在标注的材料、AI 建议以及字段自身内容做比对。
idle_session_ms30000空闲多久之后关闭并提交一个会话。
flush_interval_ms5000浏览器提交的节奏。
pause_thresholds_ms[500,1000,2000,5000,10000]在每个阈值上分别统计停顿次数。
disclose_to_annotatorstrue显示录制提示。关闭它会记录一条警告。

检测相关的键在写作过程检测中说明。

选择 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_msiki_mean_ms按键间隔的集中趋势
iki_p10/p25/p75/p90_ms间隔分布的形状
iki_log_sdiki_log_cv对数尺度上的离散度。低意味着节拍均匀,也就意味着誊抄。

用对数尺度是因为按键间隔的分布严重右偏。超过 30 秒的间隔被排除在这些统计之外,免得一次喝咖啡的休息就主导了它们。

停顿

字段含义
pause_counts每个配置阈值上的次数
pause_total_ms停顿的总时长
pre_word_pause_mean_ms开始写一个词之前的平均停顿
pre_sentence_pause_mean_ms标点之后的平均停顿
intraword_iki_median_ms词内间隔的中位数,可作为键盘熟练度的代理指标

爆发

字段含义
burstsburst_mean_charsburst_max_chars连续产出的统计
p_bursts由停顿终止的爆发
r_bursts由修改终止的爆发

修改

字段含义
backspacesdeletesundo_events删除行为
non_terminal_edits在文本末尾之后方发生的编辑,意味着写作者回头做了修改
caret_jumps非相邻的光标移动
revision_ratiochars_deleted / chars_typed

外部插入

字段含义
paste_eventspasted_charslargest_paste_chars粘贴的量
pasted_fraction最终文本中来自粘贴的比例
drop_events拖放插入
silent_insert_chars / silent_insert_ratio没有对应击键的字符
external_insert_chars / external_insert_ratio同上,但排除自引和对材料的引用
paste_sourcespaste_chars_by_source按来源标签统计的次数和字符数

检测请用 external_insert_ratiosilent_insert_ratio 统计的是全部无声插入,包括正当的那些。

注意力

字段含义
blur_eventsblur_total_ms离开页面的时间
max_blur_before_insert_ms一次大段插入之前紧邻的最长离开时间
first_keystroke_latency_ms打出第一个字符之前的思考时间

完整性

字段含义
untrusted_eventsInputEvent.isTrusted === false,意味着脚本或自动化输入
composition_eventsIME 组字
virtual_keyboard检测到移动端或软键盘

数据存在哪里

两个去处,出于两种不同的理由。

原始事件流进 SQLite

<task_dir>/project.sqlite,表 typing_sessions,每个会话一行,走的是和备忘、编码手册相同的持久化层。

可查询的摘要列以反范式的方式与完整 JSON 摘要和一个 zlib 压缩的事件 blob 并排存放:

bash
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__ 哨兵值下,改用 phasepage 两列来标识:

sql
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}",这样它就能随标注一起进入管理员仪表板和导出。

原始事件流刻意不放在那里。那个文件在每次保存标注时都会被完整重新序列化并原子重写,而一份长回答有数千个事件。

导出

两种导出都需要主动开启,所以行为数据不会不小心进入数据集发布。

摘要特征与标注并列导出

yaml
export_include_typing_dynamics: true

annotations.csv 旁边生成 typing_dynamics.csv(或 .tsv),每个(用户、实例、字段)组合一行,含摘要特征和检测器的判定结果。

原始事件流

bash
python -m potato.export.cli <config.yaml> --format keystrokes

写出 keystroke_sessions.parquetkeystroke_events.parquet,未安装 pyarrow 时回退到 JSONL。更大范围的导出器见 Parquet 导出

python
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,说明配置从未到达模板。

追踪器在,但没有会话出现

检查字段识别:

js
const el = document.querySelector('textarea');
window.keystrokeTracker.getFieldIdentity(el);   // null means it is not tracked

null 表示该元素既没有 schemalabel_name 属性,也没有用 ::: 分隔的 name,或者它被配置排除了。

silent_insertion 把每个移动端标注者都标记了

不应该这样,因为 virtual_keyboard 为 true 时这条规则会被抑制。如果检测误触发,请检查客户端是否设置了那个标志。见误报表。

project.sqlite 在变大

每次击键约 2 字节。设成 fidelity: summary 可以保留特征、丢掉事件流,或者用 typing_store.delete_for_user() 删除某一位参与者的数据。

自动化测试的数字看起来不对

浏览器自动化以接近零的间隔打字,这确实会触发 implausible_speed。那是标记在正常工作,不是 bug。

延伸阅读

有关实现细节,请参阅源代码文档