Skip to content

Journalisation des frappes

Potato peut enregistrer les pauses, les rafales, les révisions et les collages derrière une réponse en texte libre sans enregistrer aucun des caractères que tape l'annotateur.

Potato peut enregistrer comment une réponse en texte libre a été produite sans enregistrer la réponse. Chaque événement porte un horodatage, un type d'entrée, une classe de touche et une variation de longueur ; aucun ne porte le caractère qui a été tapé. À partir de ce flux, Potato calcule une quarantaine de caractéristiques de synthèse et les stocke avec l'annotation.

L'objectif est de distinguer une réponse rédigée, tapée avec les pauses et les remords de quelqu'un qui la construit, d'une réponse recopiée depuis une autre fenêtre ou collée depuis un agent conversationnel. Lisez les réponses finies : elles se ressemblent. Les journaux, non.

La journalisation des frappes demande Potato 2.7.2 ou une version ultérieure, et elle est désactivée par défaut : keystroke_logging.enabled vaut false tant que vous ne le changez pas, donc une mise à niveau ne se met jamais à enregistrer qui que ce soit. Pour les règles bâties sur ces données, voir Détection du processus de rédaction. Avant de pointer la fonction vers des participants humains, lisez Éthique de la journalisation des frappes.

Démarrage rapide

yaml
keystroke_logging:
  enabled: true

C'est toute la configuration minimale. Chaque champ de texte libre du projet se met à produire un flux d'événements aveugle au contenu, une synthèse et un jeu de signalements de détection.

Un exemple exécutable est livré avec Potato :

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

Avertissement : enabled vaut false par défaut. Mettre Potato à niveau ne démarre jamais silencieusement l'enregistrement de vos annotateurs.

Ce qui est capté

Chaque événement enregistre un horodatage, un type d'entrée, une classe de touche, la position du curseur et la variation de longueur du champ :

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

Ce qui n'est délibérément pas capté

Non captéPourquoi
Les caractères tapésLe flux reconstitue le processus, pas le texte
Le texte colléSeulement une longueur, une étiquette de source et une empreinte salée
Les brouillons intermédiairesNon reconstituables à partir des seules variations de longueur
Tout ce qui se trouve dans un champ de mot de passegetFieldIdentity refuse type="password" d'emblée
Le contenu du presse-papiers en généralLu au moment du collage pour la classification, puis jeté

Classes de touches

La touche elle-même n'est jamais conservée, seulement la famille à laquelle elle appartient :

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

Types d'entrée

Le signal principal de Potato est InputEvent.inputType sur beforeinput, pas keydown. C'est le choix technique central. Le collage, le glisser-déposer, la composition IME, la dictée, le remplissage automatique et l'annulation modifient tous un champ sans déclencher keydown du tout, si bien qu'un journaliseur qui n'écoute que keydown est aveugle précisément aux cas que cette fonction existe pour détecter.

Types d'entrée captés : insertText, insertReplacementText, insertFromPaste, insertFromDrop, insertCompositionText, insertLineBreak, insertParagraph, deleteContentBackward, deleteContentForward, deleteWordBackward, deleteWordForward, deleteByCut, deleteByDrag, historyUndo, historyRedo, plus les événements synthétiques focus, blur et keydown.

keydown et keyup restent écoutés, mais uniquement pour compter les frappes physiques et mesurer le temps d'appui. L'écart entre les caractères apparus et les touches réellement enfoncées est le signal isolé le plus fort de toute la collecte. Voir silent_insert_ratio plus bas.

Quels champs sont instrumentés

Par défaut, tous les champs de texte libre : le schéma text, les zones de réponse libre à l'intérieur de radio et multiselect, et les zones de texte de justification ou de notes dans text_edit, pairwise, trajectory_eval et les schémas similaires.

Les champs sont identifiés par les attributs schema et label_name que Potato appose déjà sur chaque champ de saisie d'annotation, avec repli sur la découpe de l'attribut name sur :::.

Restreignez la portée avec l'une ou l'autre liste :

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

Ou excluez un seul élément dans du HTML personnalisé :

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

Référence de configuration

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
CléDéfautSignification
enabledfalseInterrupteur principal. Rien n'est capté quand il vaut false.
fidelityeventsoff désactive ; summary calcule les caractéristiques sans stocker de flux ; events stocke les deux.
include_schemas[]Liste blanche de noms de schémas. Vide signifie tous.
exclude_schemas[]Liste noire, appliquée après la liste blanche.
store_eventstrueConserve les flux bruts. Ignoré sauf avec fidelity: events.
classify_paste_sourcetrueCompare les collages au passage, aux suggestions d'IA et au contenu déjà présent dans le champ.
idle_session_ms30000Durée d'inactivité avant qu'une session soit close et envoyée.
flush_interval_ms5000Cadence d'envoi du navigateur.
pause_thresholds_ms[500,1000,2000,5000,10000]Le nombre de pauses est rapporté pour chacun de ces seuils.
disclose_to_annotatorstrueAffiche un avis d'enregistrement. Le désactiver inscrit un avertissement.

Les clés de détection sont documentées dans Détection du processus de rédaction.

Choisir une fidélité

FidélitéFlux stockéRecalculer de nouvelles métriques plus tard ?À utiliser quand
offLa fonction est désactivée pour ce projet
summaryNonNonVous savez exactement quelles caractéristiques vous voulez, ou votre approbation éthique ne couvre pas la conservation des flux
eventsOuiOuiPar défaut. Environ 2 octets par frappe

events est le réglage recommandé. Une réponse de 500 mots coûte environ 5 Ko, et cela veut dire qu'une métrique à laquelle vous penserez après la collecte pourra encore être calculée.

Caractéristiques de synthèse

Une synthèse par (utilisateur, instance, champ). Les familles de caractéristiques suivent Crossley et al. (2024) ; voir les fondements de recherche.

Volume et rapport produit/processus

ChampSignification
keystrokesAppuis de touche physiques ayant produit du texte
final_charsLongueur du champ à la fin de la session
chars_typed / chars_insertedCaractères insérés par la frappe / par n'importe quel moyen
chars_deletedCaractères supprimés
chars_per_keystrokeAu-dessus d'environ 1,1, du texte arrive sans frappes
active_ms / wall_msTemps passé sur le champ, hors / y compris le temps d'absence

Rythme

ChampSignification
iki_median_ms, iki_mean_msTendance centrale des intervalles entre touches
iki_p10/p25/p75/p90_msForme de la distribution des intervalles
iki_log_sd, iki_log_cvDispersion en échelle logarithmique. Une valeur basse signifie métronomique, donc transcription.

Échelle logarithmique parce que les distributions d'intervalles entre touches sont fortement asymétriques à droite. Les intervalles de plus de 30 secondes sont exclus de ces statistiques pour qu'une pause café ne les domine pas.

Pauses

ChampSignification
pause_countsNombre de pauses à chaque seuil configuré
pause_total_msTemps total passé en pause
pre_word_pause_mean_msPause moyenne avant d'entamer un mot
pre_sentence_pause_mean_msPause moyenne après une ponctuation
intraword_iki_median_msIntervalle médian à l'intérieur des mots, indicateur indirect de l'aisance au clavier

Rafales

ChampSignification
bursts, burst_mean_chars, burst_max_charsStatistiques des séquences de production
p_burstsRafales terminées par une pause
r_burstsRafales terminées par une révision

Révision

ChampSignification
backspaces, deletes, undo_eventsComportement de suppression
non_terminal_editsModifications faites en deçà de la fin du texte, signe que l'auteur est revenu en arrière pour réviser
caret_jumpsDéplacements non contigus du curseur
revision_ratiochars_deleted / chars_typed

Insertion externe

ChampSignification
paste_events, pasted_chars, largest_paste_charsVolume de collage
pasted_fractionPart du texte final qui a été collée
drop_eventsInsertions par glisser-déposer
silent_insert_chars / silent_insert_ratioCaractères sans frappe correspondante
external_insert_chars / external_insert_ratioComme ci-dessus, hors auto-citations et citations du passage
paste_sources, paste_chars_by_sourceNombre de collages et de caractères par étiquette de source

Utilisez external_insert_ratio pour la détection. silent_insert_ratio compte toute insertion silencieuse, y compris la légitime.

Attention

ChampSignification
blur_events, blur_total_msTemps passé hors de la page
max_blur_before_insert_msPlus longue absence précédant immédiatement une insertion importante
first_keystroke_latency_msTemps de réflexion avant le premier caractère

Intégrité

ChampSignification
untrusted_eventsInputEvent.isTrusted === false, c'est-à-dire une saisie scriptée ou automatisée
composition_eventsComposition IME
virtual_keyboardClavier mobile ou logiciel détecté

Où les données sont stockées

Deux destinations, pour deux raisons différentes.

Les flux bruts vont dans SQLite

<task_dir>/project.sqlite, table typing_sessions, une ligne par session, par la même couche de persistance que les mémos et le livre de codes.

Des colonnes de synthèse interrogeables sont dénormalisées à côté d'une synthèse JSON complète et d'un blob d'événements compressé par zlib :

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

Le flux est stocké sous forme d'un blob compressé par session plutôt que d'une ligne par frappe. Il n'est jamais relu qu'en entier, et à environ 2 octets par événement, un schéma à une ligne par frappe mettrait des dizaines de millions de lignes dans un fichier de projet sans aucun bénéfice pour les requêtes.

Pages de phase

Les réponses en texte libre de la phase de formation et des enquêtes de pré-étude ou de post-étude sont captées elles aussi. Ces pages n'ont pas d'identifiant d'instance : leurs sessions sont donc regroupées sous la sentinelle __phase_page__ que le reste du système comportemental utilise déjà, et identifiées par leurs colonnes phase et page :

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

C'est ce qui fait fonctionner l'exemple de calibration. Une tâche de recopie du passage en phase de formation fournit des exemples de transcription que l'on peut distinguer des réponses rédigées ordinaires par la seule colonne phase.

Les synthèses vont dans user_state.json

L'esquisse compacte est recopiée dans <output_annotation_dir>/<user>/user_state.json, sous instance_id_to_behavioral_data.<instance>.typing_summaries, avec la clé "{schema}:::{label}", si bien qu'elle voyage avec l'annotation jusqu'au tableau de bord d'administration et aux exports.

Les flux bruts n'y vont délibérément pas. Ce fichier est entièrement re-sérialisé et réécrit de façon atomique à chaque enregistrement d'annotation, et une réponse longue représente des milliers d'événements.

Exporter

Les deux exports sont optionnels, pour que les données comportementales ne se retrouvent jamais par accident dans une publication de jeu de données.

Caractéristiques de synthèse à côté des annotations

yaml
export_include_typing_dynamics: true

Produit typing_dynamics.csv (ou .tsv) à côté d'annotations.csv, une ligne par (utilisateur, instance, champ), avec les caractéristiques de synthèse et le verdict du détecteur.

Flux bruts

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

Écrit keystroke_sessions.parquet et keystroke_events.parquet, avec repli sur JSONL quand pyarrow n'est pas installé. Voir Export Parquet pour l'exportateur plus général.

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"])

Points de terminaison de l'API

MéthodeRouteObjet
POST/api/track_typingReçoit les sessions terminées envoyées par le navigateur
GET/api/typing_summary/<instance_id>Synthèses pour une instance, utilisateur courant
GET/admin/api/writing_processRécapitulatif par annotateur (clé d'administration requise)

Les sessions sont résumées côté serveur. Le navigateur n'envoie jamais de synthèse déjà calculée, donc les chiffres ne peuvent pas être falsifiés par un client modifié, et une métrique ajoutée plus tard peut être recalculée à partir des flux stockés.

Comment fonctionnent les sessions

Une session commence quand un champ prend le focus et se termine au premier de ces événements : perte du focus, navigation vers une autre instance, idle_session_ms d'inactivité, ou déchargement de la page. Les sessions terminées sont envoyées toutes les flush_interval_ms, et via navigator.sendBeacon au déchargement pour qu'une session en cours ne soit pas perdue.

Les sessions multiples sur un même champ sont fusionnées avant l'écriture de la synthèse dans l'état utilisateur, si bien que quitter un champ puis y revenir se lit comme une seule réponse plutôt que comme plusieurs réponses suspectement courtes. Les comptes et les durées s'additionnent. Les statistiques de distribution sont des approximations pondérées par les frappes : utilisez les flux bruts si vous avez besoin de la distribution agrégée exacte.

Dépannage

Aucune donnée n'est enregistrée

Vérifiez keystroke_logging.enabled: true et que fidelity ne vaut pas off. Dans la console du navigateur, window.keystrokeTracker doit exister avec isInitialized === true. S'il vaut undefined, la configuration n'est jamais parvenue au gabarit.

Le traceur existe mais aucune session n'apparaît

Vérifiez l'identification des champs :

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

null signifie que l'élément n'a ni attribut schema ni label_name, ni name séparé par :::, ou qu'il est exclu par la configuration.

silent_insertion signale tous les annotateurs sur mobile

Cela ne devrait pas arriver, car la règle est neutralisée quand virtual_keyboard est vrai. Si la détection se trompe, vérifiez que le client a bien positionné ce drapeau. Voir le tableau des faux positifs.

project.sqlite grossit

Environ 2 octets par frappe. Passez à fidelity: summary pour garder les caractéristiques et abandonner les flux, ou utilisez typing_store.delete_for_user() pour supprimer les données d'un participant.

Les chiffres semblent faux pour les tests automatisés

L'automatisation de navigateur tape avec des intervalles quasi nuls, ce qui déclenche véritablement implausible_speed. C'est la règle qui fonctionne, pas un bogue.

Pour aller plus loin

Pour les détails d'implémentation, consultez la documentation source.