문서 편집기는 사용자가 '적용' 버튼을 누르기 전부터 이미 잘못된, 매우 합리적인 AI 제안을 보여줄 수 있습니다.
공유 크리에이티브 브리프에 있는 단락을 상상해 보세요:
The father leaves the room. (아버지가 방을 나선다.)
작성자가 에이전트에게 이 문단을 재작성하도록 요청합니다. 제안이 준비되는 동안, 협업자가 브리프를 변경합니다:
The father stays and helps his daughter. (아버지는 남아 딸을 돕는다.)
에이전트는 첫 번째 문장의 다듬어진 버전을 반환합니다. 누군가 '적용' 버튼을 클릭합니다. 문제는 더 이상 생성된 문장의 품질이 아닙니다. 문제는 이 제안이 현재 사람들이 편집하고 있는 문서와 여전히 관련이 있는지 여부입니다.
공유 편집 시스템은 보류 중인 재작성이 구식 결정을 나타낸다는 것을 이해하지 못한 채 업데이트를 병합할 수 있습니다. 이를 위해서는 제안 워크플로우에 별도의 확인 절차가 필요합니다.
제안을 편집과 분리 유지하기
여기서 검토된 구현은 Breatic의 공개 저장소에 있습니다. 이 문서 편집 도구는 공유 문서를 즉시 재작성하는 대신 '제안 카드(proposal card)'를 반환합니다.
제안은 대상 공간(target Space), 블록 ID, 블록의 원래 일반 텍스트(plain text), 제안된 내용, 그리고 고유한 제안 ID를 가지고 있습니다. 댓글, 재작성 및 삽입 기능은 동일한 제안 형태를 사용하지만, 각각의 대상에 대해 다른 요구 사항을 가집니다.
원래 텍스트가 중요합니다. ID는 애플리케이션에게 모델이 어떤 블록을 보았는지 알려줄 뿐입니다. 그 블록이 여전히 같은 내용을 담고 있는지는 알려주지 않습니다.
재작성의 경우, 관련 확인 절차는 개념적으로 다음과 같습니다:
const block = findBlockById(currentDocument, proposal.blockId);
if (block === null) return { outcome: "blockGone" };
...
이것은 독립적인 API 예제가 아닌 설명적 스케치입니다. 구현에서는 이 확인 절차가 편집기의 현재 ProseMirror 문서를 읽습니다. 모든 속성(attribute)과 마크(mark)의 전체 구조적 해시(structural hash)가 아니라, 블록의 일반 텍스트를 비교합니다.
협업자가 단락을 다시 작성했다면, 새로운 문구를 조용히 대체하는 대신 제안이 거부됩니다. 사용자는 현재 텍스트에 대해 다른 제안을 요청할 수 있습니다.
A document rewrite presented for review before replacement
실제 예시 프로젝트 캡처입니다. 이 장면은 설명용이며, 재작성은 이 이미지에 적용되지 않았습니다.
비동기 준비 후 다시 확인하기 (Check again after asynchronous preparation)
핸들러에서 검사를 수행하는 것만으로는 충분하지 않습니다. 만약 해당 핸들러가 이후 작업을 기다리게 된다면(awaits work).
제안된 Markdown은 에디터 블록(editor blocks)이 되어야 합니다. 이 블록들을 준비하는 과정 역시 미디어 설정(media configuration)을 기다릴 수 있습니다. 그 시간 동안 다른 편집이 도착하거나, 대상(target)이 사라지거나, 현재 에디터가 읽기 전용(read-only) 상태가 될 수 있습니다.
따라서 랜딩 함수(landing function)는 현재 상태를 두 번 확인합니다. 준비되기 전과 동기식 블록 쓰기(synchronous block write) 직전에 완료된 후 다시 한번 확인하는 것입니다. 간소화된 개요는 다음과 같습니다:
const initial = inspectCurrentState();
if (initial !== null) return initial;
...
inspectCurrentState는 단순한 텍스트 비교 이상의 것을 의미합니다. 이 함수는 요청이 여전히 사용 가능한지(available), 이 제안이 이미 적용되었는지(landed), 현재 쓰기 허용 상태(write admission)가 어떤지, 에디터가 편집 가능한지(editable), 그리고 현재 대상(target)을 확인합니다.
두 번째 확인에서 빠진 점에 유의하세요: await 전에 저장된 대상 위치(target position)입니다. 구현은 ID를 사용하여 블록을 다시 조회합니다. 협업자가 단어는 바꾸지 않고도 이를 이동시켰을 수 있으며, 심지어 표 셀(table cell) 안으로 옮겼을 수도 있습니다. 새 콘텐츠는 현재 위치에 맞게 정규화(normalized)되어야 합니다.
한 번 적용하는 것은 영구적인 추적 기록이 필요합니다 (A durable trace)
두 번째 클릭은 동일한 제안을 다시 삽입해서는 안 됩니다. 로컬의 applied = true 플래그는 새로고침(reload) 후에 무슨 일이 일어났는지 설명할 수 없습니다.
삽입되거나 재작성된 콘텐츠의 경우, 구현은 제안(proposal) ID와 각 출력 블록의 인덱스를 사용하여 루트 블록 ID를 도출합니다. 나중에 렌더링할 때 공유 문서에서 해당 흔적을 찾을 수 있습니다. 댓글은 대신 자신의 스레드에 제안 메타데이터를 담고 있으며, '완료된(landed)' 것으로 간주되려면 여전히 문서 내의 범위를 가져야 합니다.
이러한 흔적은 문서의 현재 상태를 설명합니다. 만약 작성 내용이 취소되어 출력물이 사라지면, 해당 제안은 더 이상 완료된 것으로 간주되지 않습니다. 다시 실행되면, 그 흔적이 다시 존재합니다.
또한 이 작성 내용은 하나의 '실행 취소(undo)' 단계로 묶입니다. 다중 블록 제안을 수락하는 것이 사용자가 그 조각들을 하나하나 되돌리거나, 수락하기 직전에 입력한 텍스트를 되돌릴 필요가 없어야 합니다.
행복한 경로(happy path)보다는 간극(gap) 테스트
유용한 회귀 사례는 비동기 준비 과정을 의도적으로 일시 중지합니다. 이 과정이 일시 중지된 동안, 테스트는 대상(target)을 변경하거나 제거하거나 편집기를 읽기 전용으로 만듭니다. 그런 다음 준비를 해제하고 결과를 확인합니다.
공개 테스트 스위트는 이러한 사례들이 changed, blockGone, 또는 readOnly를 반환하고, 현재 문서를 온전하게 유지하며, 실행 취소 항목을 추가하지 않음을 단언합니다. 다른 사례들은 준비 과정 중에 대상이 표 셀 안팎으로 이동하는 것을 다루며, 출력물이 새 위치에 맞게 정규화(normalized)되는지 검증합니다.
테스트는 또한 반복적인 적용, 실행 취소 및 다시 실행, 그리고 두 번째 Yjs 문서를 통해 결과 문서를 읽는 경우도 포함합니다. 이들은 저장소에 존재하는 단언 사항이며, 본 기사는 새로운 벤치마크나 전체 스위트의 신선한 실행을 보고하는 것이 아닙니다.
이 검사가 해결하지 못하는 것들
일반 텍스트 비교는 의도적으로 범위가 좁은 방어 장치입니다. 이는 모든 서식 전용 변경 사항을 감지하거나, 모델이 개요(brief)를 해석한 것이 정확함을 확립하지 못합니다. 최종 브라우저 측 검사 역시 나중에 도착할 수 있는 모든 원격 업데이트에 대한 분산 잠금(distributed lock)은 아닙니다.
그 역할은 더 구체적입니다: 현재 로컬 문서가 해당 제안이 기반했던 텍스트를 포함하지 않을 때 제안을 거부하고, 비동기 일시 정지(asynchronous pause) 이전에 포착된 상태(state)를 사용하는 것을 방지하는 것입니다.
협업형 AI 편집 기능을 검토할 실질적인 지점은 “사용자가 이 제안을 수락한 시점”과 “공유 문서가 변경된 시점” 사이의 순간입니다. 그 간격 자체는 제안이 완성된 것처럼 보일지라도 고유한 상태(state), 권한(permissions), 그리고 실패 사례(failure cases)를 가지고 있습니다.
구현 참고 자료 (Implementation references)
고지 사항: 이 문서는 AI의 도움을 받아 작성되었으며, 링크된 공개 구현 및 테스트를 기술적 출처로 사용했습니다.