擊鍵記錄
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、留存、參與者權利
- 行為追蹤 - 這個功能所處的更大的互動追蹤系統
- 品質控制 - 注意力檢查和金標準
- 管理員儀表板 - 寫作過程面板所在之處
有關實現細節,請參閱原始碼文件。