Skip to content

Tastenanschlag-Logging

Potato kann die Pausen, Schreibschübe, Überarbeitungen und Einfügungen hinter einer Freitextantwort aufzeichnen, ohne eines der Zeichen aufzuzeichnen, die ein Annotator tippt.

Potato kann aufzeichnen, wie eine Freitextantwort entstanden ist, ohne die Antwort selbst aufzuzeichnen. Jedes Ereignis trägt einen Zeitstempel, einen Eingabetyp, eine Tastenklasse und eine Längenänderung; keines davon trägt das getippte Zeichen. Aus diesem Strom berechnet Potato etwa vierzig zusammenfassende Merkmale und speichert sie bei der Annotation.

Der Zweck ist, eine verfasste Antwort, getippt mit den Pausen und zweiten Gedanken von jemandem, der sie sich erarbeitet, von einer abgetippten zu unterscheiden, die aus einem anderen Fenster abgeschrieben wurde, oder von einer eingefügten, die aus einem Chatbot stammt. Liest man die fertigen Antworten, ähneln sie einander. Die Logs tun das nicht.

Tastenanschlag-Logging braucht Potato 2.7.2 oder neuer und ist standardmäßig aus: keystroke_logging.enabled ist false, bis Sie es setzen, ein Upgrade beginnt also nie damit, jemanden aufzuzeichnen. Zu den Regeln, die auf diesen Daten aufbauen, siehe Schreibprozess-Erkennung. Bevor Sie das auf menschliche Teilnehmende richten, lesen Sie Ethik des Tastenanschlag-Loggings.

Schnellstart

yaml
keystroke_logging:
  enabled: true

Das ist die gesamte Minimalkonfiguration. Jedes Freitextfeld im Projekt erzeugt von da an einen inhaltsblinden Ereignisstrom, eine Zusammenfassung und eine Reihe von Erkennungsmarkierungen.

Ein lauffähiges Beispiel wird mit Potato ausgeliefert:

bash
python potato/flask_server.py start examples/advanced/keystroke-logging/config.yaml -p 8000

Warnung: enabled ist standardmäßig false. Ein Upgrade von Potato beginnt nie stillschweigend damit, Ihre Annotatoren aufzuzeichnen.

Was erfasst wird

Jedes Ereignis hält einen Zeitstempel, einen Eingabetyp, eine Klasse von Taste, die Cursorposition und die Änderung der Feldlänge fest:

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"}}

Was bewusst nicht erfasst wird

Nicht erfasstWarum
Die getippten ZeichenDer Strom rekonstruiert den Prozess, nicht den Text
Eingefügter TextNur eine Länge, ein Quellenlabel und ein gesalzener Hash
Zwischenstände des EntwurfsAus Längenänderungen allein nicht rekonstruierbar
Alles in einem PasswortfeldgetFieldIdentity verweigert type="password" rundweg
Zwischenablage-Inhalte allgemeinBeim Einfügen zur Klassifikation gelesen, dann verworfen

Tastenklassen

Die Taste selbst wird nie gespeichert, nur die Familie, zu der sie gehört:

letter, digit, punct, space, enter, bksp, del, nav, mod, func, unknown

Eingabetypen

Potatos primäres Signal ist InputEvent.inputType bei beforeinput, nicht keydown. Das ist die zentrale technische Entscheidung. Einfügen, Drag-and-drop, IME-Komposition, Diktat, Autofill und Rückgängigmachen verändern ein Feld alle, ohne keydown überhaupt auszulösen, ein Logger, der nur auf keydown hört, ist also blind für genau die Fälle, wegen derer es diese Funktion gibt.

Erfasste Eingabetypen: insertText, insertReplacementText, insertFromPaste, insertFromDrop, insertCompositionText, insertLineBreak, insertParagraph, deleteContentBackward, deleteContentForward, deleteWordBackward, deleteWordForward, deleteByCut, deleteByDrag, historyUndo, historyRedo, dazu die synthetischen focus, blur und keydown.

keydown und keyup werden weiterhin abgehört, aber nur, um körperliche Tastenanschläge zu zählen und die Haltedauer zu messen. Die Lücke zwischen aufgetauchten Zeichen und tatsächlich gedrückten Tasten ist das stärkste einzelne Signal, das erhoben wird. Siehe silent_insert_ratio weiter unten.

Welche Felder instrumentiert werden

Standardmäßig jedes Freitextfeld: das text-Schema, Freitextboxen innerhalb von radio und multiselect sowie die Textbereiche für Begründungen oder Notizen in text_edit, pairwise, trajectory_eval und ähnlichen Schemata.

Felder werden über die Attribute schema und label_name identifiziert, die Potato ohnehin an jedes Annotations-Eingabefeld schreibt; ersatzweise wird das name-Attribut an ::: aufgeteilt.

Den Umfang schränken Sie mit einer der beiden Listen ein:

yaml
keystroke_logging:
  enabled: true
  include_schemas: [rationale]      # allowlist; empty = all fields
  exclude_schemas: [scratch_notes]  # denylist

Oder Sie nehmen ein einzelnes Element in eigenem HTML aus:

html
<textarea data-keystroke-logging="off" ...></textarea>

Konfigurationsreferenz

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
SchlüsselVorgabeBedeutung
enabledfalseHauptschalter. Bei false wird nichts erfasst.
fidelityeventsoff deaktiviert; summary berechnet Merkmale, speichert aber keinen Strom; events speichert beides.
include_schemas[]Positivliste von Schemanamen. Leer bedeutet alle.
exclude_schemas[]Negativliste, angewendet nach der Positivliste.
store_eventstrueRohströme dauerhaft speichern. Ohne fidelity: events wirkungslos.
classify_paste_sourcetrueEinfügungen mit der Passage, KI-Vorschlägen und dem bisherigen Feldinhalt vergleichen.
idle_session_ms30000Untätigkeit, nach der eine Sitzung geschlossen und übertragen wird.
flush_interval_ms5000Übertragungstakt des Browsers.
pause_thresholds_ms[500,1000,2000,5000,10000]Pausen werden zu jeder dieser Schwellen gezählt.
disclose_to_annotatorstrueHinweis auf die Aufzeichnung anzeigen. Abschalten erzeugt eine Warnung.

Die Schlüssel unter detection sind in Schreibprozess-Erkennung dokumentiert.

Eine Erfassungstiefe wählen

ErfassungstiefeStrom gespeichertNeue Metriken später berechenbar?Wann sinnvoll
offFunktion für dieses Projekt deaktiviert
summaryNeinNeinSie wissen sicher, welche Merkmale Sie brauchen, oder Ihre Ethikfreigabe deckt das Aufbewahren von Strömen nicht ab
eventsJaJaVorgabe. Etwa 2 Byte pro Tastenanschlag

events ist die empfohlene Einstellung. Eine Antwort mit 500 Wörtern kostet etwa 5 KB, und eine Metrik, die Ihnen erst nach der Datenerhebung einfällt, lässt sich damit noch berechnen.

Zusammenfassende Merkmale

Eine Zusammenfassung pro (Nutzer, Instanz, Feld). Die Merkmalsfamilien folgen Crossley et al. (2024); siehe die Forschungsgrundlage.

Umfang und Verhältnis von Produkt zu Prozess

FeldBedeutung
keystrokesKörperliche keydowns, die Text erzeugt haben
final_charsFeldlänge am Sitzungsende
chars_typed / chars_insertedZeichen, die durch Tippen / auf beliebigem Weg eingefügt wurden
chars_deletedEntfernte Zeichen
chars_per_keystrokeÜber etwa 1,1 heißt: Text kommt ohne Tastenanschläge herein
active_ms / wall_msZeit am Feld, ohne / mit der Zeit der Abwesenheit

Rhythmus

FeldBedeutung
iki_median_ms, iki_mean_msZentrale Tendenz der Abstände zwischen Tastenanschlägen
iki_p10/p25/p75/p90_msForm der Abstandsverteilung
iki_log_sd, iki_log_cvStreuung auf logarithmischer Skala. Niedrig heißt metronomisch, und das heißt Abtippen.

Logarithmische Skala, weil die Verteilungen der Abstände zwischen Tastenanschlägen stark rechtsschief sind. Abstände über 30 Sekunden bleiben aus diesen Statistiken heraus, damit eine Kaffeepause sie nicht dominiert.

Pausen

FeldBedeutung
pause_countsAnzahl zu jeder konfigurierten Schwelle
pause_total_msGesamte Zeit in Pausen
pre_word_pause_mean_msMittlere Pause vor dem Beginn eines Wortes
pre_sentence_pause_mean_msMittlere Pause nach einem Satzzeichen
intraword_iki_median_msMedianabstand innerhalb von Wörtern, ein Näherungsmaß für Tippfertigkeit

Schreibschübe

FeldBedeutung
bursts, burst_mean_chars, burst_max_charsStatistiken zu Produktionsläufen
p_burstsSchübe, die durch eine Pause enden
r_burstsSchübe, die durch eine Überarbeitung enden

Überarbeitung

FeldBedeutung
backspaces, deletes, undo_eventsLöschverhalten
non_terminal_editsBearbeitungen hinter dem Textende, die schreibende Person ist also zurückgegangen, um zu überarbeiten
caret_jumpsNicht benachbarte Cursorbewegungen
revision_ratiochars_deleted / chars_typed

Externe Einfügung

FeldBedeutung
paste_events, pasted_chars, largest_paste_charsUmfang der Einfügungen
pasted_fractionAnteil des endgültigen Textes, der eingefügt wurde
drop_eventsEinfügungen per Drag-and-drop
silent_insert_chars / silent_insert_ratioZeichen ohne zugehörigen Tastenanschlag
external_insert_chars / external_insert_ratioWie oben, ohne Selbstzitate und Zitate aus der Passage
paste_sources, paste_chars_by_sourceAnzahl und Zeichen je Quellenlabel

Verwenden Sie external_insert_ratio für die Erkennung. silent_insert_ratio zählt jede stille Einfügung, auch die legitime.

Aufmerksamkeit

FeldBedeutung
blur_events, blur_total_msZeit weg von der Seite
max_blur_before_insert_msLängste Abwesenheit unmittelbar vor einer großen Einfügung
first_keystroke_latency_msNachdenkzeit vor dem ersten Zeichen

Integrität

FeldBedeutung
untrusted_eventsInputEvent.isTrusted === false, also per Skript oder automatisiert erzeugte Eingabe
composition_eventsIME-Komposition
virtual_keyboardMobile oder Bildschirmtastatur erkannt

Wo die Daten gespeichert werden

Zwei Ziele, aus zwei verschiedenen Gründen.

Rohströme gehen nach SQLite

<task_dir>/project.sqlite, Tabelle typing_sessions, eine Zeile pro Sitzung, über dieselbe Persistenzschicht wie Memos und Codebuch.

Abfragbare Zusammenfassungsspalten liegen denormalisiert neben einer vollständigen JSON-Zusammenfassung und einem zlib-gepackten Ereignis-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;"

Der Strom wird als ein gepackter Blob pro Sitzung gespeichert, nicht als eine Zeile pro Tastenanschlag. Er wird ohnehin immer nur als Ganzes zurückgelesen, und bei etwa 2 Byte pro Ereignis brächte ein Schema mit einer Zeile pro Tastenanschlag zig Millionen Zeilen in eine Projektdatei, ohne dass Abfragen davon profitieren.

Phasenseiten

Freitextantworten in der Trainingsphase und in Vor- oder Nachbefragungen werden ebenfalls erfasst. Diese Seiten haben keine Instanz-ID, ihre Sitzungen werden deshalb unter dem Platzhalter __phase_page__ einsortiert, den der Rest des Verhaltenssystems ohnehin verwendet, und stattdessen über ihre Spalten phase und page identifiziert:

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

Genau das lässt das Kalibrierungsbeispiel funktionieren. Eine Abtipp-Aufgabe in der Trainingsphase liefert Beispiele fürs Abtippen, die sich allein anhand von phase von gewöhnlichen, verfassten Antworten unterscheiden lassen.

Zusammenfassungen gehen nach user_state.json

Die kompakte Skizze wird nach <output_annotation_dir>/<user>/user_state.json unter instance_id_to_behavioral_data.<instance>.typing_summaries gespiegelt, mit dem Schlüssel "{schema}:::{label}", sodass sie mit der Annotation ins Admin-Dashboard und in die Exporte wandert.

Rohströme landen dort bewusst nicht. Diese Datei wird bei jedem Speichern einer Annotation vollständig neu serialisiert und atomar überschrieben, und eine lange Antwort sind Tausende von Ereignissen.

Exportieren

Beide Exporte sind Opt-in, damit Verhaltensdaten nie versehentlich in einer Datensatzveröffentlichung landen.

Zusammenfassende Merkmale neben den Annotationen

yaml
export_include_typing_dynamics: true

Erzeugt typing_dynamics.csv (oder .tsv) neben annotations.csv, eine Zeile pro (Nutzer, Instanz, Feld), mit den zusammenfassenden Merkmalen und dem Urteil des Detektors.

Rohströme

bash
python -m potato.export.cli <config.yaml> --format keystrokes

Schreibt keystroke_sessions.parquet und keystroke_events.parquet, ersatzweise JSONL, wenn pyarrow nicht installiert ist. Zum Exporter insgesamt siehe Parquet-Export.

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-Endpunkte

MethodeRouteZweck
POST/api/track_typingAbgeschlossene Sitzungen vom Browser entgegennehmen
GET/api/typing_summary/<instance_id>Zusammenfassungen für eine Instanz, aktueller Nutzer
GET/admin/api/writing_processAuswertung pro Annotator (Admin-Schlüssel erforderlich)

Sitzungen werden serverseitig zusammengefasst. Der Browser schickt nie eine fertig berechnete Zusammenfassung, die Zahlen lassen sich also nicht mit einem manipulierten Client fälschen, und eine später hinzugefügte Metrik lässt sich aus den gespeicherten Strömen nachberechnen.

Wie Sitzungen funktionieren

Eine Sitzung beginnt, wenn ein Feld den Fokus erhält, und endet mit dem, was zuerst eintritt: Fokusverlust, Navigation zu einer anderen Instanz, idle_session_ms ohne Aktivität oder das Entladen der Seite. Abgeschlossene Sitzungen werden alle flush_interval_ms übertragen, beim Entladen zusätzlich über navigator.sendBeacon, damit eine laufende Sitzung nicht verloren geht.

Mehrere Sitzungen am selben Feld werden zusammengeführt, bevor die Zusammenfassung in den Nutzerzustand geschrieben wird, das Verlassen und Zurückkehren zu einem Feld erscheint also als eine Antwort statt als mehrere verdächtig kurze. Anzahlen und Dauern addieren sich. Verteilungsstatistiken sind mit Tastenanschlägen gewichtete Näherungen, greifen Sie also auf die Rohströme zurück, wenn Sie eine exakte gepoolte Verteilung brauchen.

Fehlerbehebung

Es werden keine Daten aufgezeichnet

Prüfen Sie keystroke_logging.enabled: true und dass fidelity nicht off ist. In der Browser-Konsole sollte window.keystrokeTracker existieren, mit isInitialized === true. Ist es undefined, hat die Konfiguration das Template nie erreicht.

Der Tracker existiert, aber es erscheinen keine Sitzungen

Prüfen Sie die Feldidentifikation:

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

null heißt, dass das Element weder schema- noch label_name-Attribute und kein mit ::: getrenntes name hat, oder dass es per Konfiguration ausgeschlossen ist.

silent_insertion markiert jeden mobilen Annotator

Sollte es nicht, denn die Regel wird unterdrückt, wenn virtual_keyboard wahr ist. Wenn die Erkennung danebengreift, prüfen Sie, ob der Client dieses Flag gesetzt hat. Siehe die Tabelle der Falsch-Positiven.

project.sqlite wächst

Etwa 2 Byte pro Tastenanschlag. Setzen Sie fidelity: summary, um die Merkmale zu behalten und die Ströme fallen zu lassen, oder verwenden Sie typing_store.delete_for_user(), um die Daten einer teilnehmenden Person zu entfernen.

Die Zahlen sehen bei automatisierten Tests falsch aus

Browser-Automatisierung tippt mit Abständen nahe null, und das löst implausible_speed tatsächlich aus. Das ist die Markierung bei der Arbeit und kein Fehler.

Weiterführende Informationen

Implementierungsdetails finden Sie in der Quelldokumentation.