Skip to content
Announcements8 min read

작성 과정 읽기: 자유 텍스트 어노테이션을 위한 키스트로크 로깅

이제 Potato는 어노테이터가 무엇을 입력했는지는 기록하지 않으면서 자유 텍스트 답변을 어떻게 만들어 냈는지를 기록하고, 멈춤과 수정과 붙여넣기를 직접 쓰지 않고 붙여넣은 응답을 가려낼 감사 가능한 플래그로 바꿉니다.

Potato Team

한 크라우드워커가 어노테이션 작업을 열고, 지문을 읽고, 다른 탭으로 넘어갔다가 20초 뒤에 돌아오더니, 280자짜리 근거가 한 번에 입력란에 나타납니다.

그 근거 자체는 괜찮습니다. 주제에 맞고, 문법도 맞고, 지문을 인용합니다. 다른 마흔 개와 나란히 놓고 읽어도 특별히 눈에 띄는 점이 없습니다. 여기에 어떤 분류기를 갖다 대든 "아마 사람일 것"과 "판단하기 어려움" 사이의 답을 내놓을 텐데, 그 분류기들은 대부분의 텍스트에 대해 그렇게 말합니다.

완성된 답변은 그것이 어디서 왔는지 말해 주지 않습니다. 20초와 한 번의 동작은 말해 줍니다.

이제 Potato가 그것을 기록합니다. 자유 텍스트 필드는 응답이 만들어진 과정을 내용 없이 담은 스트림으로 수집하고, 약 40개의 특징으로 요약하고, 그 결과에 이름이 붙은 소수의 규칙을 적용할 수 있습니다. 기본값은 비활성이며 한 줄로 켭니다.

yaml
keystroke_logging:
  enabled: true

Potato의 키스트로크 로깅: 작성, 옮겨 적음, 붙여넣기는 완성된 글이 같아 보여도 작성 과정에서는 다르게 나타납니다키스트로크 로깅

작성, 옮겨 적음, 붙여넣기

이 기능이 기대고 있는 연구는 어느 정도 정리되어 있습니다. Crossley와 동료들은 논증 에세이 500편을 모으고 다른 작업자들에게 그것을 옮겨 적게 한 뒤, 랜덤 포레스트로 직접 쓴 글과 옮겨 적은 글을 99% 정확도로 구분했습니다. Deane 등과 Zhang 등도 각각 독립적으로 같은 구분을 확인했습니다. Asher 등은 Prolific 위에 크라우드소싱 버전을 만들어, 제출한 글의 길이에 비해 키 입력 수가 지나치게 적은 참가자를 표시했습니다.

이 신호는 모든 연구에서 일관되게 나타납니다. 실제 작성에는 문장과 단어 앞에 더 긴 멈춤이 있고, 삽입과 삭제가 더 많으며, 키 간격의 분산이 큽니다. 옮겨 적기는 직선적이고 버스트 위주이며 분산이 작습니다. 붙여넣기에는 간격이라 할 것이 거의 없습니다.

이 중 어느 것도 텍스트에는 드러나지 않습니다. 전부 로그에는 드러납니다.

keydown이 아니라 beforeinput인 이유

나머지 기능이 작동할지 말지를 가르는 기술적 결정은 이 하나입니다.

키스트로크 로거를 만드는 뻔한 방법은 keydown을 듣는 것입니다. 그리고 그것이 바로 실패하는 방법인데, 붙여넣기, 드래그 앤 드롭, IME 조합, 받아쓰기, 자동 완성, 실행 취소는 모두 keydown을 한 번도 발생시키지 않은 채 필드 내용을 바꾸기 때문입니다. keydown만 보는 로거는 이 기능이 잡아내려는 바로 그 경우들에 눈이 멀어 있습니다.

Potato의 주 신호는 beforeinput에서 읽는 InputEvent.inputType입니다. 이 이벤트는 위의 경우 모두에서 발생하고, 그중 무엇이 일어났는지도 알려 줍니다. keydownkeyup도 여전히 듣지만 용도가 다릅니다. 사람이 실제로 누른 키의 수를 세는 데 씁니다.

수집되는 것 중 가장 쓸모 있는 것은 이 두 숫자의 차이입니다. 대응하는 키 입력 없이 필드에 나타난 문자는 silent_insert_chars로, 응답에서 그것이 차지하는 비율은 silent_insert_ratio로 기록됩니다. 페이지가 억제한 붙여넣기, 텍스트를 주입한 확장 프로그램, 받아쓰기 스트림, 입력란을 채우는 스크립트 중 어느 것도 키 입력을 만들지 않지만, 모두 문자는 만들어 냅니다.

무엇이 기록되고 무엇이 기록되지 않는가

각 이벤트는 타임스탬프, 입력 유형, 키 부류, 캐럿 위치, 길이 변화량을 담습니다.

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

키 자체는 저장되지 않고 어느 부류에 속하는지만 저장됩니다. letter, digit, punct, space, enter, bksp, del, nav, mod, func, unknown이 그 부류입니다. 붙여넣은 텍스트는 길이, 출처 레이블, 세션마다 솔트를 붙인 해시로 축약됩니다. 비밀번호 필드는 아예 거부합니다. 스트림으로 응답을 복원할 수는 없으며, 그것이 요점입니다. 스트림은 과정을 서술할 뿐 내용에 대해서는 아무 말도 하지 않습니다.

평범한 행동이 플래그에 걸리지 않게 해 주는 것은 붙여넣기 출처 분류입니다. 붙여넣기가 발생하면 Potato는 어노테이션 대상 지문, 페이지에 표시된 AI 제안, 그리고 필드에 이미 있던 내용과 비교한 뒤 레이블만 남기고 비교 내용은 버립니다. 지문을 인용하면 instance_text로 읽힙니다. 자기가 쓴 초안을 옮기면 self로 읽힙니다. 둘 다 외부 삽입으로 세지 않습니다.

여섯 개의 규칙, 그리고 모델인 척하는 것은 없음

탐지는 서버 쪽에서 세 단계로 실행됩니다. 기본으로 켜져 있는 것은 첫 단계뿐입니다.

첫 단계는 이름이 붙은 여섯 개의 플래그로, 각각 임계값이 명시되어 있고 그 플래그를 발생시킨 특징 값을 함께 돌려줍니다.

플래그발생 조건심각도
paste_dominant최종 텍스트의 절반 이상이 붙여넣기로 들어옴suspect
silent_insertion삽입된 문자의 30% 이상에 대응하는 키 입력이 없음suspect
transcription_rhythm기계적인 리듬 그리고 수정 없음 그리고 멈춤이 거의 없음review
offscreen_composition페이지를 10초 이상 벗어난 직후의 큰 외부 삽입suspect
implausible_speed응답 전체에 걸쳐 약 180 wpm 이상이 지속됨review
synthetic_input브라우저가 isTrusted === false를 보고함suspect

transcription_rhythm이 세 조건의 결합인 것은 의도적입니다. 기계적인 타이핑만 놓고 보면 빠른 타자수입니다. 아무것도 지우지 않는 것은 신중한 사람입니다. 거의 멈추지 않는 것은 짧은 답변입니다. 세 가지가 동시에 성립할 때에만 옮겨 적기의 신호가 됩니다. 그리고 이 규칙은 리듬이라 읽을 것이 없는 80자 미만의 응답은 아예 건너뜁니다.

두 번째 단계는 보정입니다. 키스트로크 특징은 작성 과제에 크게 좌우되므로, 한 문장짜리 근거에 맞춘 임계값은 다섯 단락짜리 글에는 맞지 않습니다. python -m potato.typing_detect calibrate config.yaml은 각 기준값을 프로젝트 자체 세션의 꼬리 백분위수에 맞춰 다시 맞춥니다. 쓸 만한 세션이 최소 30개는 필요하고, 보정된 값은 내장 기본값의 3배 이내로 제한되므로 균질한 집단이 임계값을 자기 중앙값 쪽으로 끌어당길 수 없습니다.

세 번째는 지도 학습입니다. 레이블이 있다면 fit_supervised()가 특징 행렬 위에 실제 분류기를 학습시킵니다. scikit-learn은 필요할 때 임포트되며 Potato의 의존성이 아닙니다.

사전 학습된 모델은 함께 배포되지 않습니다. 저장소에 레이블이 달린 코퍼스가 없으니, 아무것도 없는 상태에서 뽑아낸 계수를 배포한다면 검증 수치를 지어내는 셈이 됩니다. 대신 배포되는 것은 직접 읽고, 이견을 제기하고, 덮어쓸 수 있는 여섯 개의 규칙입니다.

레이블이 필요하다면 훈련 단계에서 자체 연구 안에서 만들어 낼 수 있습니다. 어노테이터에게 지문을 그대로 따라 치는 준비 과제를 주면, 그 세션들은 당신의 어노테이터가 당신의 과제에서 만든 진짜 옮겨 적기 예시가 되고, 평소의 답변은 작성 부류가 됩니다. Crossley의 코퍼스가 만들어진 방식이 그렇고, 보정 예제가 그 과정을 처음부터 끝까지 구성해 줍니다.

임계값은 서버에서 평가되며 브라우저로 전송되지 않습니다. 임계값을 공개하면 어노테이터에게 얼마나 천천히 붙여넣어야 하는지를 정확히 알려 주는 셈이 됩니다.

데이터가 가는 곳

원시 스트림은 <task_dir>/project.sqlite의 SQLite로, 세션당 한 행씩, 메모와 코드북이 쓰는 것과 같은 영속성 계층을 거쳐 저장됩니다. 이벤트는 델타 인코딩 후 zlib으로 압축되어 측정된 값으로 이벤트당 1.7바이트를 차지하므로, 500단어 응답이 약 5KB입니다.

이 데이터는 의도적으로 user_state.json에 들어가지 않습니다. 그 파일은 어노테이션을 저장할 때마다 통째로 다시 쓰이는데, 긴 응답이면 이벤트가 3,000개쯤 됩니다. 압축된 요약만 행동 데이터로 "{schema}:::{label}" 키에 미러링되어, 어노테이션과 함께 대시보드와 내보내기로 따라갑니다.

훈련 단계와 사전·사후 설문의 자유 텍스트 답변도 수집됩니다. 이 페이지들에는 인스턴스 id가 없으므로, 해당 세션은 기존의 __phase_page__ 센티널 아래로 모이고 대신 phasepage로 식별됩니다. 지문을 따라 치게 하는 방법이 통하는 이유가 여기에 있습니다. 옮겨 적기 예시는 phase만으로도 평소 답변과 분리할 수 있습니다.

내보내기는 둘 다 옵트인입니다. export_include_typing_dynamics: true는 어노테이션 옆에 typing_dynamics.csv 사이드카 파일을 쓰고, python -m potato.export.cli <config.yaml> --format keystrokes는 원시 스트림을 Parquet으로 씁니다. pyarrow가 없으면 JSONL로 대체됩니다. 행동 데이터가 실수로 데이터셋 배포에 포함되는 일은 없습니다.

사람에게 적용하기 전에

플래그는 사람이 검토할 근거입니다. 증거가 아니며, 자동 반려나 대금 지급 보류, 계정 차단에 연결해서는 안 됩니다.

여기서의 실패 양상은 성실한 어노테이터를 부당하게 의심하는 것이고, 규칙에 걸리는 경우들은 결코 특이한 상황이 아닙니다. 일부는 처리되어 있습니다. 지문 인용과 자기 초안 옮기기는 출처 분류로 억제되고, 소프트 키보드와 IME 조합은 silent_insertion을 억제합니다. 둘 다 keydown을 안정적으로 발생시키지 않아 그대로 두면 모든 삽입이 조용한 삽입으로 보이기 때문입니다. 처리되지 않은 것도 있습니다. 받아쓰기는 플래그가 붙습니다. 문법 교정 확장 프로그램도 플래그가 붙습니다. 일부 보조 기술은 신뢰되지 않은 이벤트를 만들어 synthetic_input에 걸립니다. 문서에서 연구가 보조 기술 사용자에게 열려 있다면 그 규칙을 꺼 두라고 분명히 적어 둔 이유가 이것입니다. 일하기 위해 필요한 도구를 쓴 사람에게 플래그를 해명하게 만드는 것보다 낫습니다.

그다음은 산수 문제입니다. 응답의 5%가 붙여넣기이고 규칙이 세션의 5%를 표시한다면, 표시된 것 중 대부분은 여전히 성실한 작업일 수 있습니다. 실제 부정행위가 정말 드문 플랫폼에서는 오탐률이 다소 낮은 규칙이라도 진짜로 잡아내는 건수보다 잘못된 의심을 더 많이 만들어 냅니다. 보정된 임계값은 이 문제를 개선하는 것이 아니라 드러낼 뿐입니다. 꼬리 백분위수는 아무도 잘못한 것이 없는 집단을 포함해 어떤 집단에서든 그 꼬리 비율만큼을 표시합니다.

고지는 기본으로 켜져 있고, 이를 끄면 시작 시 경고가 기록됩니다. 이 기본값이 존재하는 이유는 타이밍 패턴이 행동 생체 정보이기 때문입니다. 타이밍 패턴은 개인을 식별할 수 있고 여러 맥락의 계정을 연결할 수 있으며, 연구 문헌에서는 이를 통해 타이핑 숙련도, 제2언어 사용 여부, 인지 부하를 추론해 왔습니다. 문장에 레이블을 달겠다고 신청한 사람 중 누구도 그런 것을 예상하지 않았습니다. Potato는 그 어느 것도 계산하지 않고 그런 도구를 제공하지도 않지만, 보관하는 데이터는 그런 분석을 뒷받침할 수 있습니다. 이는 도구가 아니라 여러분이 관리해야 할 문제입니다. typing_store.delete_for_user()는 참가자 한 명의 스트림을 지우고, fidelity: summary는 특징은 남기면서 생체 정보에 해당하는 세부는 버립니다. 윤리 페이지에 동의 문구 예시, 보관 지침, 그리고 GDPR 제22조와 IRB 심의, 플랫폼의 반려 정책에 대한 설명이 있습니다.

writing_process_risk는 관리자 대시보드의 작성 과정 패널에 순위 매기기용 지표로 표시되며, 기존 suspicion_score와는 별개입니다. 두 숫자는 서로에게 섞여 들어가지 않으므로 어느 쪽도 다른 쪽의 의미를 슬그머니 바꾸지 않습니다.

켜는 방법

이 기능은 Potato 2.7.2에 포함됩니다. 맨 위의 한 줄짜리보다 조금 더 갖춘 설정은 다음과 같습니다.

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은 계측된 필드에 붙여넣는 것을 막습니다. 다만 정당한 인용까지 막고, 마음먹은 사람은 대신 타이핑할 수 있으므로, 보통은 flag로 두고 검토하는 편이 낫습니다.

실행해 볼 수 있는 프로젝트는 examples/advanced/keystroke-logging/에, 보정 안내는 examples/advanced/keystroke-calibration/에 있습니다.

문서

업그레이드

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

기존 프로젝트는 여러분이 요청하기 전까지 아무것도 달라지지 않습니다. keystroke_logging.enabled의 기본값은 false이므로, 업그레이드만으로 누군가의 기록이 시작되는 일은 없습니다.