キーストロークロギング
Potatoは、アノテーターが入力した文字を一切記録せずに、自由記述の回答の背後にあるポーズ、バースト、修正、貼り付けを記録できます。
Potatoは、自由記述の回答そのものを記録することなく、それがどのように作られたかを記録できます。各イベントが持つのはタイムスタンプ、入力タイプ、キーのクラス、長さの変化で、どれも入力された文字は含みません。Potatoはそのストリームから約40個の要約特徴量を計算し、アノテーションと一緒に保存します。
目的は、考えながらポーズを置いたり考え直したりして入力された「作成された回答」を、別のウィンドウから打ち直した「書き写しの回答」やチャットボットから落とし込んだ「貼り付けの回答」と見分けることです。出来上がった回答を読んでも、どれも似ています。ログは似ていません。
キーストロークロギングには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が主に使う信号は、keydownではなくbeforeinputにおけるInputEvent.inputTypeです。これが中心となる技術的な選択です。貼り付け、ドラッグ&ドロップ、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スキーマ、radioとmultiselectの中の自由記述ボックス、text_edit、pairwise、trajectory_evalなどのスキーマにある根拠やメモのテキストエリアが含まれます。
フィールドは、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 | [] | スキーマ名の許可リスト。空はすべてを意味します。 |
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 | はい | はい | デフォルト。1キーストロークあたり約2バイト |
推奨設定はeventsです。500語の回答でおよそ5 KBで、データ収集後に思いついた指標も後から計算できます。
要約特徴量
(ユーザー、インスタンス、フィールド)の組ごとに1つの要約が作られます。特徴量のまとまりはCrossleyら(2024)に沿っています。研究上の裏付けを参照してください。
量とプロダクト対プロセス
| フィールド | 意味 |
|---|---|
keystrokes | テキストを生じさせた物理的なキー押下 |
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秒を超える間隔を除外しており、休憩1回が全体を支配しないようにしています。
ポーズ
| フィールド | 意味 |
|---|---|
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 | 最初の1文字までの思考時間 |
完全性
| フィールド | 意味 |
|---|---|
untrusted_events | InputEvent.isTrusted === false。スクリプトや自動化による入力を意味します |
composition_events | IMEでの変換 |
virtual_keyboard | モバイルまたはソフトキーボードを検出 |
データの保存先
保存先は2つあり、それぞれ理由が異なります。
生のストリームはSQLiteへ
<task_dir>/project.sqliteのtyping_sessionsテーブルに、1セッション1行で、メモやコードブックと同じ永続化層を通して保存されます。
検索しやすいように、要約カラムが非正規化された形で、完全な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;"ストリームは1キーストローク1行ではなく、1セッション1つの圧縮blobとして保存されます。読み出すときは常に丸ごと読むうえ、1イベント約2バイトなので、1キーストローク1行にすると数千万行がプロジェクトファイルに入るだけで、クエリ上の利点はありません。
フェーズページ
トレーニングフェーズや事前・事後アンケートの自由記述回答も取得されます。これらのページにはインスタンス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: trueannotations.csvの隣にtyping_dynamics.csv(または.tsv)を生成します。(ユーザー、インスタンス、フィールド)ごとに1行で、要約特徴量と検出結果が入ります。
生のストリーム
python -m potato.export.cli <config.yaml> --format keystrokeskeystroke_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> | 現在のユーザーの、1インスタンス分の要約 |
GET | /admin/api/writing_process | アノテーター別の集計(管理者キーが必要) |
セッションはサーバー側で要約されます。ブラウザが計算済みの要約を送ることはないため、改造したクライアントで数値を偽装することはできませんし、後から追加した指標も保存済みのストリームから再計算できます。
セッションの仕組み
セッションはフィールドがフォーカスを受けたときに始まり、フォーカスを失う、別のインスタンスに移動する、idle_session_msだけ操作がない、ページがアンロードされる、のうち最も早いもので終わります。完了したセッションはflush_interval_msごとに送信され、アンロード時にはnavigator.sendBeaconで送られるため、進行中のセッションが失われることはありません。
同じフィールド上の複数のセッションは、要約がユーザー状態に書き込まれる前にマージされます。そのため、フィールドを離れて戻ってきた場合も、不自然に短い複数の回答ではなく1つの回答として読まれます。件数と時間は加算されます。分布の統計量はキーストロークで重み付けした近似なので、正確なプールされた分布が必要な場合は生のストリームを使ってください。
トラブルシューティング
データがまったく記録されない
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が大きくなっていく
1キーストロークあたり約2バイトです。fidelity: summaryにすれば特徴量を残してストリームを削れますし、typing_store.delete_for_user()で特定の参加者のデータを削除できます。
自動テストで数値がおかしい
ブラウザの自動操作はほぼ間隔ゼロで入力するため、implausible_speedに実際に該当します。これはバグではなく、フラグが正しく働いている状態です。
参考情報
- 執筆プロセス検出 - 6つのルールと3つの検出層
- キーストロークロギングの倫理 - 同意、IRB、データ保持、参加者の権利
- 行動トラッキング - この機能が組み込まれている、より広いインタラクション追跡の仕組み
- 品質管理 - 注意チェックとゴールドスタンダード
- 管理者ダッシュボード - 執筆プロセスパネルの場所
実装の詳細については、ソースドキュメントを参照してください。