Skip to content
Announcements2 min read

读懂写作过程:面向自由文本标注的击键记录

Potato 现在可以记录标注者如何写出自由文本答案,而不记录他们键入的内容,并把停顿、修改和粘贴变成可审计的标记,指出哪些回答是粘贴来的而不是写出来的。

Potato Team

一位众包工作者打开你的标注任务,读完那段材料,切到另一个标签页,二十秒后回来,一段 280 字符的理由说明一下子出现在框里。

这段理由说明没什么问题。它切题、语法通顺、引用了材料。把它和另外四十份放在一起读,看不出什么异样。你能拿来检测它的每一个分类器,都会给你一个介于「大概是人写的」和「说不清」之间的答案,而这正是那些分类器对大多数文本的说法。

写完的答案不会告诉你它从哪儿来。那二十秒和那一下子会。

Potato 现在会记录这件事。自由文本字段可以采集一份内容无关的事件流,记下回答是怎么产生的,把它汇总成约四十项特征,再对结果跑一小组具名规则。它默认关闭,一行就能打开:

yaml
keystroke_logging:
  enabled: true

Potato 中的击键记录:撰写、誊抄和粘贴的回答,即便成品文本看不出差别,过程也是不一样的击键记录

撰写、誊抄、粘贴

这背后的研究已经相当稳固。Crossley 和同事收集了 500 篇论辩文章,再让另一批工作者把它们誊抄一遍,用随机森林以 99% 的准确率把真实写作与誊抄分开。Deane 等人和 Zhang 等人各自独立地得到了同样的分离结果。Asher 等人在 Prolific 上做了众包版本,标记出击键数相对于提交内容长度明显偏低的参与者。

这个特征在所有研究里都一致。真实的撰写在句子和词语之前有更长的停顿,插入和删除更多,按键间隔的方差也更大。誊抄是线性的、成串爆发的、方差很低。粘贴几乎没有间隔可言。

这些在文本里一点都看不到。在日志里全都看得到。

为什么用 beforeinput 而不是 keydown

这是决定这个功能其余部分能不能成立的那个技术决策。

写击键记录器最顺手的做法是监听 keydown。它也是会失败的做法,因为粘贴、拖放、IME 组字、听写、自动填充和撤销都会改变字段内容,却一次 keydown 都不触发。只看 keydown 的记录器,恰好对这个功能存在的目的所要捕捉的那些情况是瞎的。

Potato 的主要信号是 beforeinput 上的 InputEvent.inputType,它对上述所有情况都会触发,并说明发生的是哪一种。keydownkeyup 仍然被监听,但用途不同:统计一个人实际按下了多少个键。

这两个数字之间的差距是采集到的最有用的东西。字段里出现了、却没有对应击键的字符被记为 silent_insert_chars,它们在回答中的占比记为 silent_insert_ratio。被页面拦下的粘贴、注入文本的扩展、听写流、往框里填内容的脚本:它们都不产生击键,却都产生字符。

记录了什么,没记录什么

每个事件带有一个时间戳、一个输入类型、一个按键类别、一个光标位置和一个长度变化量:

text
{t_ms: 1240, input_type: "insertText",            key_class: "letter", pos: 41, 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"}}

按键本身从不存储,只存它属于哪一族:letterdigitpunctspaceenterbkspdelnavmodfuncunknown。粘贴进来的文本被简化成一个长度、一个来源标签和一个按会话加盐的哈希。密码字段直接被拒绝。你没法从事件流还原出回答,而这正是重点:事件流描述的是过程,对内容只字不提。

粘贴来源分类是把正常行为挡在标记之外的那道机制。粘贴发生时,Potato 会把它和正在标注的材料、页面上显示的任何 AI 建议,以及字段里已有的内容做比对,然后只保留标签、丢掉比对内容。引用材料读作 instance_text。挪动自己的草稿读作 self。两者都不算外部插入。

六条规则,没有任何冒充模型的东西

检测在服务端分三层运行。只有第一层默认开启。

第一层是六个具名标记,每个都有明确的阈值,每个都返回触发它的特征值:

标记触发条件严重程度
paste_dominant最终文本有一半或以上来自粘贴suspect
silent_insertion插入的字符中 ≥30% 背后没有击键suspect
transcription_rhythm节拍均匀没有修改几乎不停顿review
offscreen_composition离开页面 ≥10 秒后紧接着出现一大段外部插入suspect
implausible_speed整份回答持续维持在约 180 wpm 以上review
synthetic_input浏览器报告 isTrusted === falsesuspect

transcription_rhythm 是有意做成合取的。单看节拍均匀,那是打字快的人。单看从不删东西,那是做事仔细的人。单看几乎不停顿,那是回答短。只有三者同时成立,你才拿到誊抄的特征,而且这条规则会完全跳过 80 字符以下的回答,那里根本没有节奏可读。

第二层是校准。击键特征很大程度上取决于写作任务,所以为一句话理由说明调好的阈值,用在五段文字上就是错的。python -m potato.typing_detect calibrate config.yaml 会把每个界限重新拟合到你自己项目会话的某个尾部百分位上。它至少需要 30 个可用会话,而且校准值会被限制在内置默认值的 3 倍以内,这样一个同质化的人群就没法把阈值拖到自己的中位数上。

第三层是有监督的。如果你有标签,fit_supervised() 会在特征矩阵上训练一个真正的分类器。scikit-learn 是延迟导入的,不是 Potato 的依赖。

没有随附任何预训练模型。仓库里没有带标签的语料,而发布一份凭空推导出来的拟合系数等于编造一个验证数字。随附的是六条你能读懂、能不同意、也能覆盖掉的规则。

如果你想要标签,培训阶段可以在你自己的研究内部产出它们。给标注者一个誊抄材料的热身任务:那些会话是你的标注者在你的任务上留下的真实誊抄样本,而他们平常的答案就是撰写那一类。Crossley 的语料就是这么建起来的,校准示例把这套流程端到端地搭好了。

阈值在服务端评估,从不发送到浏览器。公布它们等于精确地告诉标注者要多慢地粘贴。

数据去了哪里

原始事件流写进 SQLite,位于 <task_dir>/project.sqlite,每个会话一行,走的是和备忘、编码手册相同的持久化层。事件经过差分编码和 zlib 压缩,实测每个事件 1.7 字节,因此一份 500 词的回答大约占 5 KB。

它们刻意不进 user_state.json。那个文件在每次保存标注时都会被整体重写,而一份长回答大约有 3,000 个事件。只有紧凑的摘要会镜像进行为数据,键为 "{schema}:::{label}",这样它就能随标注一起进入仪表板和导出。

培训阶段以及前置或后置问卷里的自由文本答案也会被采集。那些页面没有实例 id,所以它们的会话归到既有的 __phase_page__ 哨兵值下,改用 phasepage 来标识。这正是誊抄材料那一招能奏效的原因:誊抄样本仅凭阶段就能和普通答案分开。

两种导出都需要主动开启。export_include_typing_dynamics: true 会在标注旁边写出一个 typing_dynamics.csv 附属文件,python -m potato.export.cli <config.yaml> --format keystrokes 把原始事件流写入 Parquet,没有 pyarrow 时回退到 JSONL。行为数据不会不小心进入数据集发布。

在把它对准人之前

一个标记是供人审查的证据。它不是证明,也绝不能接到自动拒绝、扣款或封禁上。

这里的失败模式是冤枉一位诚实的标注者,而触发这些规则的情况并不罕见。有些已经处理了:引用材料和挪动自己的草稿会被来源分类抑制;软键盘和 IME 组字会抑制 silent_insertion,因为两者都不能可靠地发出 keydown,否则每一次插入看起来都是无声的。有些没有处理。听写会被标记。语法扩展会被标记。某些辅助技术会产生不可信事件,从而触发 synthetic_input,所以文档直白地写着:如果你的研究对辅助技术使用者开放,你应该关掉那条规则,而不是去向一个只是在用自己工作所需工具的人解释一个标记。

然后是那道算术题。如果你有 5% 的回答是粘贴的,而你的规则标记了 5% 的会话,那么被标记出来的大部分仍然可能是诚实的工作。在一个不端行为确实罕见的平台上,一条哪怕误报率不高的规则,产生的错误指控也会多过真正的捕获。校准过的阈值只是把这一点摆明,而不是让它变好:一个尾部百分位会标出任何人群的那一部分尾部,包括一个谁都没做错事的人群。

披露默认开启,关掉它会在启动时记录一条警告。这个默认值之所以存在,是因为时间模式是一种行为生物特征。它们可以识别一个人,也可以把不同场景下的账号关联起来,而研究文献已经用它们推断过打字技能、第二语言身份和认知负荷。报名来标句子的人没料到会有这些。Potato 一样都不计算,也不提供相关工具,但你留下的数据足以支撑这类分析,这是你要管的问题,不是工具的。typing_store.delete_for_user() 可以删掉某一位参与者的事件流,fidelity: summary 保留特征而丢掉生物特征细节。伦理页面提供了知情同意示例文本、留存建议,以及关于 GDPR 第 22 条、IRB 审查和平台拒绝政策的说明。

writing_process_risk 会作为排序辅助出现在管理员仪表板的写作过程面板里,和既有的 suspicion_score 分开。两个数字互不折算,所以谁也不会悄悄改变对方的含义。

怎么打开

这个功能随 Potato 2.7.2 发布。比开头那一行更完整的配置:

yaml
keystroke_logging:
  enabled: true
  fidelity: events              # off | summary | events
  include_schemas: [rationale]  # empty means every free-text field
  disclose_to_annotators: true
  detection:
    enabled: true
    on_external_insert: flag    # allow | warn | block | flag

on_external_insert: block 会阻止往被记录的字段里粘贴。它同样会挡掉合理的引用,而且铁了心的人可以改成手打,所以通常 flag 加人工审查是更划算的取舍。

一个可运行的项目在 examples/advanced/keystroke-logging/,校准的完整走查在 examples/advanced/keystroke-calibration/

文档

升级

bash
pip install --upgrade potato-annotation==2.7.2

现有项目在你主动要求之前不会有任何变化。keystroke_logging.enabled 默认为 false,所以升级不会开始记录任何人。