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。

延伸閱讀

有關實現細節,請參閱原始碼文件