Skip to content

キーストロークロギング

Potatoは、アノテーターが入力した文字を一切記録せずに、自由記述の回答の背後にあるポーズ、バースト、修正、貼り付けを記録できます。

Potatoは、自由記述の回答そのものを記録することなく、それがどのように作られたかを記録できます。各イベントが持つのはタイムスタンプ、入力タイプ、キーのクラス、長さの変化で、どれも入力された文字は含みません。Potatoはそのストリームから約40個の要約特徴量を計算し、アノテーションと一緒に保存します。

目的は、考えながらポーズを置いたり考え直したりして入力された「作成された回答」を、別のウィンドウから打ち直した「書き写しの回答」やチャットボットから落とし込んだ「貼り付けの回答」と見分けることです。出来上がった回答を読んでも、どれも似ています。ログは似ていません。

キーストロークロギングには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"}}

意図的に取得しないもの

取得しないもの理由
入力された文字ストリームが復元するのはプロセスであって、テキストではありません
貼り付けられたテキスト長さ、ソースのラベル、ソルト付きハッシュのみ
途中の下書き長さの変化量だけからは復元できません
パスワードフィールドの内容getFieldIdentitytype="password"を端から拒否します
クリップボードの内容全般貼り付け時に分類のために読み、その後破棄します

キークラス

キーそのものは保存されず、どの系統に属するかだけが残ります。

letterdigitpunctspaceenterbkspdelnavmodfuncunknown

入力タイプ

Potatoが主に使う信号は、keydownではなくbeforeinputにおけるInputEvent.inputTypeです。これが中心となる技術的な選択です。貼り付け、ドラッグ&ドロップ、IMEでの変換、音声入力、オートフィル、取り消しは、いずれもkeydownをまったく発火させずにフィールドを変更するため、keydownだけを見るロガーは、この機能が検出しようとしているケースをちょうど見落とします。

取得される入力タイプ:insertTextinsertReplacementTextinsertFromPasteinsertFromDropinsertCompositionTextinsertLineBreakinsertParagraphdeleteContentBackwarddeleteContentForwarddeleteWordBackwarddeleteWordForwarddeleteByCutdeleteByDraghistoryUndohistoryRedo、および合成されたfocusblurkeydown

keydownkeyupも引き続き監視していますが、目的は物理的なキーストロークを数えることと、押下時間を測ることだけです。現れた文字数と実際に押されたキー数の差は、収集される中で最も強い単独の信号です。後述のsilent_insert_ratioを参照してください。

どのフィールドが計測対象になるか

デフォルトではすべての自由記述フィールドが対象です。textスキーマ、radiomultiselectの中の自由記述ボックス、text_editpairwisetrajectory_evalなどのスキーマにある根拠やメモのテキストエリアが含まれます。

フィールドは、Potatoがすべてのアノテーション入力に付けているschema属性とlabel_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[]スキーマ名の許可リスト。空はすべてを意味します。
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はいはいデフォルト。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_msiki_mean_msキー間間隔の代表値
iki_p10/p25/p75/p90_ms間隔の分布の形
iki_log_sdiki_log_cv対数スケールでのばらつき。小さいほど一定のリズムで、書き写しを示します。

対数スケールを使うのは、キー間間隔の分布が大きく右に裾を引くためです。この統計量からは30秒を超える間隔を除外しており、休憩1回が全体を支配しないようにしています。

ポーズ

フィールド意味
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_ratioを使ってください。silent_insert_ratioは、正当なものも含めてすべての無音挿入を数えます。

注意

フィールド意味
blur_eventsblur_total_msページから離れていた時間
max_blur_before_insert_ms大きな挿入の直前にあった最長の離席時間
first_keystroke_latency_ms最初の1文字までの思考時間

完全性

フィールド意味
untrusted_eventsInputEvent.isTrusted === false。スクリプトや自動化による入力を意味します
composition_eventsIMEでの変換
virtual_keyboardモバイルまたはソフトキーボードを検出

データの保存先

保存先は2つあり、それぞれ理由が異なります。

生のストリームはSQLiteへ

<task_dir>/project.sqlitetyping_sessionsテーブルに、1セッション1行で、メモやコードブックと同じ永続化層を通して保存されます。

検索しやすいように、要約カラムが非正規化された形で、完全な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;"

ストリームは1キーストローク1行ではなく、1セッション1つの圧縮blobとして保存されます。読み出すときは常に丸ごと読むうえ、1イベント約2バイトなので、1キーストローク1行にすると数千万行がプロジェクトファイルに入るだけで、クエリ上の利点はありません。

フェーズページ

トレーニングフェーズや事前・事後アンケートの自由記述回答も取得されます。これらのページにはインスタンスIDがないため、セッションは行動追跡の仕組みが既に使っている__phase_page__センチネルの下にまとめられ、代わりにphaseカラムとpageカラムで識別されます。

sql
SELECT phase, page, count(*) FROM typing_sessions GROUP BY phase, page;

キャリブレーションの例が成り立つのはこのおかげです。トレーニングフェーズで文章を書き写す課題を出せば、phaseだけで通常の作成された回答と区別できる書き写しの実例が得られます。

要約はuser_state.json

コンパクトな要約は<output_annotation_dir>/<user>/user_state.jsoninstance_id_to_behavioral_data.<instance>.typing_summariesにミラーされ、キーは"{schema}:::{label}"です。これによってアノテーションと一緒に管理者ダッシュボードやエクスポートまで運ばれます。

生のストリームは意図的にそこへは入れていません。このファイルはアノテーションを保存するたびに全体が再シリアライズされてアトミックに書き直され、長い回答は数千イベントになるためです。

エクスポート

どちらのエクスポートもオプトインなので、行動データが手違いでデータセットの公開物に含まれることはありません。

アノテーションと並べて要約特徴量を出す

yaml
export_include_typing_dynamics: true

annotations.csvの隣にtyping_dynamics.csv(または.tsv)を生成します。(ユーザー、インスタンス、フィールド)ごとに1行で、要約特徴量と検出結果が入ります。

生のストリーム

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>現在のユーザーの、1インスタンス分の要約
GET/admin/api/writing_processアノテーター別の集計(管理者キーが必要)

セッションはサーバー側で要約されます。ブラウザが計算済みの要約を送ることはないため、改造したクライアントで数値を偽装することはできませんし、後から追加した指標も保存済みのストリームから再計算できます。

セッションの仕組み

セッションはフィールドがフォーカスを受けたときに始まり、フォーカスを失う、別のインスタンスに移動する、idle_session_msだけ操作がない、ページがアンロードされる、のうち最も早いもので終わります。完了したセッションはflush_interval_msごとに送信され、アンロード時にはnavigator.sendBeaconで送られるため、進行中のセッションが失われることはありません。

同じフィールド上の複数のセッションは、要約がユーザー状態に書き込まれる前にマージされます。そのため、フィールドを離れて戻ってきた場合も、不自然に短い複数の回答ではなく1つの回答として読まれます。件数と時間は加算されます。分布の統計量はキーストロークで重み付けした近似なので、正確なプールされた分布が必要な場合は生のストリームを使ってください。

トラブルシューティング

データがまったく記録されない

keystroke_logging.enabled: trueになっていることと、fidelityoffでないことを確認してください。ブラウザのコンソールで、window.keystrokeTrackerが存在しisInitialized === trueになっているはずです。undefinedであれば、設定がテンプレートまで届いていません。

トラッカーはあるがセッションが現れない

フィールドの識別を確認してください。

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

nullは、その要素にschema属性もlabel_name属性もなく、:::で区切られたnameもないか、設定によって除外されていることを意味します。

silent_insertionがモバイルのアノテーター全員にフラグを立てる

virtual_keyboardがtrueのときこのルールは抑制されるため、本来そうはならないはずです。検出が誤作動している場合は、クライアントがそのフラグを設定しているか確認してください。偽陽性の表を参照してください。

project.sqliteが大きくなっていく

1キーストロークあたり約2バイトです。fidelity: summaryにすれば特徴量を残してストリームを削れますし、typing_store.delete_for_user()で特定の参加者のデータを削除できます。

自動テストで数値がおかしい

ブラウザの自動操作はほぼ間隔ゼロで入力するため、implausible_speedに実際に該当します。これはバグではなく、フラグが正しく働いている状態です。

参考情報

実装の詳細については、ソースドキュメントを参照してください。