문제
AI가 문서를 수정하는 기능은 겉으로 보면 쉬워 보입니다.
현재 문서 본문
-> LLM에게 수정 요청
-> 새 본문으로 저장
하지만 OpenCairn의 note는 협업 문서입니다. 여러 사용자가 동시에 편집할 수 있고, Yjs state가 source of truth입니다. 이 상황에서 문자열 덮어쓰기는 위험합니다.
문제는 다음과 같습니다.
- preview를 만든 뒤 사용자가 문서를 고칠 수 있다.
- LLM이 본 문서 상태가 이미 stale일 수 있다.
- Markdown string으로 바꾸면 rich editor 구조가 깨질 수 있다.
- wiki link, mention, comment anchor 같은 구조가 같이 갱신되어야 한다.
- apply 실패가 단순 500이 아니라 stale preview로 표현되어야 한다.
그래서 OpenCairn의 note.update는 "본문 저장"이 아니라 Yjs-backed operation입니다.
관련 코드
핵심 경로는 두 곳입니다.
packages/shared/src/agent-actions.ts
apps/api/src/lib/agent-actions.ts
apps/api/src/lib/yjs-plate-transform.ts
shared package는 API와 web이 공유하는 contract를 정의합니다. API package는 action을 실제 note에 적용합니다. Yjs transform 파일은 editor document와 AI patch 사이의 변환을 담당합니다.
단순화하면 apply flow는 이렇게 생겼습니다.
async function applyNoteUpdateAction(input: {
actionId: string;
previewId: string;
actorUserId: string;
}) {
const action = await loadAction(input.actionId);
const preview = await loadPreview(input.previewId);
const note = await loadNote(action.noteId);
if (note.stateVector !== preview.baseStateVector) {
return failAsStalePreview(action.id);
}
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, note.currentUpdate);
applyOperationsToYDoc(ydoc, preview.operations);
const nextUpdate = Y.encodeStateAsUpdate(ydoc);
await saveNoteUpdate(note.id, nextUpdate);
}
실제 코드는 더 많은 permission, transaction, event sync를 포함하지만 핵심은 같습니다.
preview 기준 상태와 현재 상태가 같은가?
-> 같으면 apply
-> 다르면 stale로 막음
state vector가 필요한 이유
Yjs에서 state vector는 문서 상태의 요약 fingerprint처럼 쓸 수 있습니다.
OpenCairn은 preview를 만들 때 기준 state vector를 기록합니다.
type NoteUpdatePreview = {
noteId: string;
baseStateVector: string;
operations: NoteUpdateOperation[];
};
그리고 apply 시점에 현재 note의 state vector와 비교합니다.
if (currentStateVector !== preview.baseStateVector) {
throw new NoteUpdateStalePreviewError();
}
이 비교가 없으면 다음 버그가 생깁니다.
10:00 사용자 A가 note.update preview 생성
10:01 사용자 B가 문서의 중요한 문단 수정
10:02 사용자 A가 오래된 preview apply
10:02 B의 수정이 날아가거나 엉뚱한 위치에 patch 적용
AI가 문서를 수정할 때 가장 중요한 것은 "잘 수정하는 것" 이전에 "오래된 수정안을 적용하지 않는 것"입니다.
preview는 UI 기능이 아니라 안전 장치다
OpenCairn에서 preview는 단순 UX가 아닙니다. preview는 apply 가능한 patch의 기준 상태를 고정하는 안전 장치입니다.
draft action
-> preview 생성
-> diff 확인
-> apply
이 분리는 다음을 가능하게 합니다.
- 사용자가 AI 수정안을 눈으로 확인한다.
- action ledger에 어떤 변경안이 제안됐는지 남는다.
- apply 전 stale detection을 할 수 있다.
- 실패 시 "다시 preview 생성"으로 복구할 수 있다.
LLM이 생성한 patch는 항상 특정 시점의 문서 상태에 대한 patch입니다. 그 시점이 사라졌다면 patch도 다시 만들어야 합니다.
Yjs transform layer
apps/api/src/lib/yjs-plate-transform.ts는 Yjs document와 editor structure 사이의 변환 경계입니다.
OpenCairn의 editor는 단순 textarea가 아닙니다. 블록 구조, 링크, mention, inline formatting이 있습니다. 따라서 agent update도 단순 string replace가 아니라 editor 구조를 보존해야 합니다.
개념적으로 transform layer는 이런 일을 합니다.
export function applyNoteUpdateOperations(input: {
ydoc: Y.Doc;
operations: NoteUpdateOperation[];
}) {
const root = getPlateRoot(ydoc);
for (const operation of input.operations) {
if (operation.type === "replace_block") {
replaceBlock(root, operation.blockId, operation.nextChildren);
}
if (operation.type === "insert_after") {
insertAfter(root, operation.afterBlockId, operation.children);
}
}
}
실제 구현에서 중요한 것은 transform 함수가 "텍스트"가 아니라 editor tree를 다룬다는 점입니다.
wiki link sync
OpenCairn note는 wiki-like graph의 일부입니다. 문서 안의 링크가 바뀌면 graph도 갱신되어야 합니다.
그래서 note.update apply는 단순히 Yjs update를 저장하고 끝나지 않습니다.
apply Yjs update
-> extract wiki links / mentions
-> sync note graph
-> emit update event
-> mark action completed
이 흐름이 있어야 agent가 문서를 수정해도 product graph가 깨지지 않습니다.
구현하며 배운 점
협업 문서에 AI를 붙일 때 "LLM이 좋은 diff를 만들 수 있는가"만 보면 안 됩니다.
더 중요한 질문은 다음입니다.
이 diff는 어떤 문서 상태를 기준으로 만들어졌나?
그 상태가 아직 유효한가?
적용 후 editor 구조와 graph가 유지되는가?
실패를 사용자가 이해하고 복구할 수 있는가?
OpenCairn의 note.update는 이 질문에 답하기 위해 preview/apply, state vector, Yjs transform, stale error를 하나의 workflow로 묶었습니다.