Skip to content
Announcements12 min read

Lire le processus de rédaction : la journalisation des frappes pour l'annotation en texte libre

Potato peut désormais enregistrer comment les annotateurs produisent leurs réponses en texte libre, sans enregistrer ce qu'ils tapent, et transformer les pauses, les révisions et les collages en signalements vérifiables pour les réponses collées plutôt que rédigées.

Potato Team

Un travailleur de plateforme ouvre votre tâche d'annotation, lit le passage, bascule vers un autre onglet, revient vingt secondes plus tard, et une justification de 280 caractères apparaît dans le champ d'un seul geste.

La justification est correcte. Elle est dans le sujet, elle est grammaticale, elle renvoie au passage. Lue à côté de quarante autres, elle ne détonne pas. Tous les classifieurs que vous pourriez lui opposer vous diront quelque chose entre « probablement humain » et « indéterminé », ce que ces classifieurs disent de la plupart des textes.

La réponse finie ne dit pas d'où elle vient. Les vingt secondes et le geste unique, si.

Potato enregistre désormais cela. Les champs de texte libre peuvent capter un flux aveugle au contenu de la façon dont une réponse a été produite, le résumer en une quarantaine de caractéristiques, et passer un petit jeu de règles nommées sur le résultat. La fonction est désactivée par défaut et s'active avec une ligne :

yaml
keystroke_logging:
  enabled: true

Journalisation des frappes dans Potato : les réponses rédigées, recopiées et collées diffèrent dans le processus même quand le texte fini ne diffère pasJournalisation des frappes

Rédigé, recopié, collé

La recherche sur laquelle cela repose est assez stable. Crossley et ses collègues ont recueilli 500 essais argumentatifs, les ont fait recopier par un second groupe de participants, et ont séparé l'écriture authentique de la transcription avec une exactitude de 99 % au moyen d'une forêt aléatoire. Deane et al. et Zhang et al. ont retrouvé la même séparation, indépendamment. Asher et al. en ont bâti la version crowdsourcing sur Prolific, en signalant les participants dont le nombre de frappes était trop faible au regard de la longueur de ce qu'ils rendaient.

La signature est la même chez tous. La rédaction réelle présente des pauses plus longues avant les phrases et les mots, davantage d'insertions et de suppressions, et une forte variance des intervalles entre les touches. La recopie est linéaire, procède par rafales, et varie peu. Le collage n'a presque pas d'intervalles.

Rien de tout cela n'est visible dans le texte. Tout l'est dans le journal.

Pourquoi beforeinput et pas keydown

C'est la décision technique dont dépend le fonctionnement de tout le reste.

La façon évidente d'écrire un journaliseur de frappes est d'écouter keydown. C'est aussi celle qui échoue : le collage, le glisser-déposer, la composition IME, la dictée, le remplissage automatique et l'annulation modifient tous le contenu d'un champ sans déclencher keydown une seule fois. Un journaliseur qui n'écoute que keydown est aveugle exactement aux cas pour lesquels la fonction existe.

Le signal principal de Potato est InputEvent.inputType sur beforeinput, qui se déclenche dans tous ces cas et indique lequel s'est produit. keydown et keyup restent écoutés, mais dans un autre but : compter les touches qu'une personne a physiquement enfoncées.

L'écart entre ces deux nombres est ce que la collecte fournit de plus utile. Les caractères apparus dans le champ sans frappe correspondante sont enregistrés dans silent_insert_chars, et leur part dans la réponse dans silent_insert_ratio. Un collage que la page a supprimé, une extension qui a injecté du texte, un flux de dictée, un script qui remplit le champ : aucun ne produit de frappes, tous produisent des caractères.

Ce qui est enregistré, et ce qui ne l'est pas

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

text
{t_ms: 1240, input_type: "insertText",            key_class: "letter", pos: 41, 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"}}

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. Le texte collé est réduit à une longueur, une étiquette de source et une empreinte salée propre à la session. Les champs de mot de passe sont refusés d'emblée. On ne peut pas reconstituer la réponse à partir du flux, et c'est le but : le flux décrit le processus et ne dit rien du contenu.

La classification de la source des collages est ce qui empêche les comportements ordinaires de finir dans les signalements. À l'arrivée d'un collage, Potato le compare au passage annoté, aux suggestions d'IA affichées sur la page et à ce qui se trouvait déjà dans le champ, puis conserve l'étiquette et jette la comparaison. Citer le passage se lit instance_text. Déplacer son propre brouillon se lit self. Ni l'un ni l'autre ne compte comme insertion externe.

Six règles, et rien qui se fasse passer pour un modèle

La détection tourne côté serveur, en trois niveaux. Seul le premier est actif par défaut.

Le premier niveau, ce sont six signalements nommés, chacun avec un seuil explicite, chacun renvoyant les valeurs de caractéristiques qui l'ont déclenché :

SignalementSe déclenche quandGravité
paste_dominantLa moitié ou plus du texte final est arrivée par collagesuspect
silent_insertion≥ 30 % des caractères insérés n'avaient aucune frappe derrière euxsuspect
transcription_rhythmRythme métronomique et aucune révision et presque aucune pausereview
offscreen_compositionUne insertion externe importante juste après ≥ 10 s passées hors de la pagesuspect
implausible_speedAu-delà d'environ 180 wpm, tenu sur toute une réponsereview
synthetic_inputLe navigateur signale isTrusted === falsesuspect

transcription_rhythm est conjonctif à dessein. Une frappe métronomique, seule, c'est un dactylo rapide. Ne jamais rien supprimer, c'est quelqu'un de soigneux. Ne presque pas faire de pause, c'est une réponse courte. Ce n'est que lorsque les trois tiennent en même temps que l'on a la signature de la recopie, et la règle ignore entièrement les réponses de moins de 80 caractères, où il n'y a aucun rythme à lire.

Le deuxième niveau, c'est la calibration. Les caractéristiques de frappe dépendent beaucoup de la tâche de rédaction : un seuil réglé pour une justification d'une phrase est faux pour cinq paragraphes. python -m potato.typing_detect calibrate config.yaml réajuste chaque coupure sur un percentile de queue des sessions de votre propre projet. Il faut au moins 30 sessions exploitables, et les valeurs calibrées sont bornées à 3× les valeurs par défaut, pour qu'une population homogène ne puisse pas tirer un seuil jusqu'à sa propre médiane.

Le troisième est supervisé. Si vous avez des étiquettes, fit_supervised() entraîne un vrai classifieur sur la matrice de caractéristiques. scikit-learn est importé à la demande et n'est pas une dépendance de Potato.

Aucun modèle pré-entraîné n'est livré. Il n'y a pas de corpus étiqueté dans le dépôt, et livrer des coefficients ajustés à partir de rien reviendrait à inventer un chiffre de validation. Ce qui est livré à la place, ce sont six règles que vous pouvez lire, contester et remplacer.

Si vous voulez des étiquettes, la phase de formation peut les produire depuis l'intérieur de votre propre étude. Donnez aux annotateurs un échauffement qui consiste à recopier le passage : ces sessions sont de véritables exemples de transcription, produits par vos annotateurs sur votre tâche, et leurs réponses ordinaires forment la classe rédigée. C'est ainsi qu'a été constitué le corpus de Crossley, et l'exemple de calibration le met en place de bout en bout.

Les seuils sont évalués sur le serveur et ne sont jamais envoyés au navigateur. Les publier indiquerait à un annotateur exactement à quelle lenteur coller.

Où cela va

Les flux bruts vont dans SQLite, dans <task_dir>/project.sqlite, une ligne par session, par la même couche de persistance que les mémos et le livre de codes. Les événements sont encodés en delta et compressés avec zlib, à 1,7 octet par événement mesuré : une réponse de 500 mots coûte donc environ 5 Ko.

Ils ne vont délibérément pas dans user_state.json. Ce fichier est réécrit intégralement à chaque enregistrement d'annotation, et une réponse longue représente environ 3 000 événements. Seule la synthèse compacte est recopiée dans les données comportementales, sous la clé "{schema}:::{label}", si bien qu'elle voyage avec l'annotation jusqu'au tableau de bord et aux exports.

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__ déjà existante et identifiées par phase et page. C'est ce qui fait fonctionner l'astuce de la recopie du passage : les exemples de transcription se distinguent des réponses ordinaires par la seule phase.

Les deux exports sont optionnels. export_include_typing_dynamics: true écrit un fichier annexe typing_dynamics.csv à côté de vos annotations, et python -m potato.export.cli <config.yaml> --format keystrokes écrit les flux bruts en Parquet, avec repli sur JSONL en l'absence de pyarrow. Les données comportementales ne se retrouvent jamais par accident dans une publication de jeu de données.

Avant de pointer cela vers des personnes

Un signalement est un indice destiné à l'examen d'un humain. Ce n'est pas une preuve, et il ne doit pas être branché sur un rejet automatique, une retenue de paiement ou un bannissement.

Le mode de défaillance, ici, c'est d'accuser un annotateur honnête, et les cas qui déclenchent les règles n'ont rien d'exotique. Certains sont pris en charge : citer le passage et réorganiser son propre brouillon sont neutralisés par la classification de la source, et les claviers logiciels comme la composition IME neutralisent silent_insertion, puisque ni l'un ni l'autre n'émet keydown de façon fiable et que chaque insertion paraîtrait sinon silencieuse. D'autres ne le sont pas. La dictée déclenchera un signalement. Les extensions de correction grammaticale aussi. Certaines technologies d'assistance produisent des événements non fiables et déclencheront synthetic_input, ce qui explique pourquoi la documentation dit sans détour que si votre étude est ouverte aux personnes qui utilisent ces technologies, mieux vaut désactiver cette règle que d'avoir à expliquer un signalement à quelqu'un qui se servait des outils dont il a besoin pour travailler.

Reste l'arithmétique. Si 5 % de vos réponses sont collées et que votre règle signale 5 % des sessions, l'essentiel de ce que vous signalez peut encore être du travail honnête. Sur une plateforme où la fraude est réellement rare, une règle au taux de faux positifs même modeste produit plus d'accusations infondées que de prises réelles. Un seuil calibré rend cela explicite, il ne l'arrange pas : un percentile de queue signale sa fraction de queue de n'importe quelle population, y compris une où personne n'a rien fait de mal.

La divulgation est activée par défaut, et la désactiver inscrit un avertissement au démarrage. Ce réglage par défaut existe parce que les profils temporels sont une donnée biométrique comportementale. Ils peuvent identifier une personne et relier des comptes entre contextes, et la littérature de recherche s'en est servie pour inférer l'aisance au clavier, le statut de locuteur non natif et la charge cognitive. Personne, en s'inscrivant pour étiqueter des phrases, ne s'attendait à cela. Potato ne calcule rien de tout cela et ne livre aucun outil pour le faire, mais les données que vous conservez rendraient l'analyse possible, et c'est à vous de gérer ce problème, pas à l'outil. typing_store.delete_for_user() supprime les flux d'un participant, et fidelity: summary conserve les caractéristiques en abandonnant le détail biométrique. La page sur l'éthique donne un exemple de formulation du consentement, des repères de conservation et des notes sur l'article 22 du RGPD, l'examen par un IRB et les politiques de rejet des plateformes.

writing_process_risk apparaît dans le panneau Processus de rédaction du tableau de bord d'administration comme aide au classement, distinct du suspicion_score existant. Aucun des deux nombres n'est intégré à l'autre, donc aucun ne modifie en douce ce que l'autre veut dire.

Comment l'activer

La fonction est livrée dans Potato 2.7.2. Une configuration plus complète que la ligne unique du début :

yaml
keystroke_logging:
  enabled: true
  fidelity: events              # off | summary | events
  include_schemas: [rationale]  # empty means every free-text field
  disclose_to_annotators: true
  detection:
    enabled: true
    on_external_insert: flag    # allow | warn | block | flag

on_external_insert: block empêche de coller dans les champs instrumentés. Il bloque aussi les citations légitimes, et qui est déterminé peut toujours retaper le texte : flag puis relecture est en général le meilleur compromis.

Un projet exécutable se trouve dans examples/advanced/keystroke-logging/, et le pas-à-pas de calibration dans examples/advanced/keystroke-calibration/.

Documentation

Mise à niveau

bash
pip install --upgrade potato-annotation==2.7.2

Rien ne change pour un projet existant tant que vous ne le demandez pas. keystroke_logging.enabled vaut false par défaut : une mise à niveau ne se met donc jamais à enregistrer qui que ce soit.