리뷰어는 대목을 글자 하나 안 틀리고 베끼라는 말을 들었고, 엔진은 그 말을 문자 그대로 받아. 첫 번째 코드 블록은 인용과 코멘트가 둘 다 있을 때만 노트를 기록하고, anchored를 단순한 검사 하나로 정해. 리뷰어가 읽은 구간 텍스트 안에 그 인용이 그대로 들어 있나? 그 전에 인용 앞뒤 공백을 잘라내. 저장하는 것도, 찾는 것도 그렇게 다듬은 인용이야. 손대는 건 그 다듬기 하나고, 퍼지 매칭도 다른 정규화도 없어. 인용 대신 바꿔 말한 리뷰어도 노트는 남겨. 그 노트는 보관되고 화면에도 나오지만, 못 찾았다는 표시가 붙어. 웹은 (not found in the text), 폰은 (not found)를 덧붙여. 첫 번째 버전 테스트는 버전 안에 있는 인용 하나, 없는 인용 하나로 두 결과를 다 못 박아.
자리를 못 찾은 노트도 남기는 건 일부러 한 선택이야. 인용을 잘못 베꼈어도 코멘트는 맞을 수 있어. 노트를 버리면 리뷰어의 실수와 함께 통찰도 같이 버려져. 표시만 달아두면 판단은 읽는 사람이 해.
가리키는 자리에 표시해
아빠는 노트가 칠해진 채로 버전을 읽어. 두 클라이언트 모두 버전의 구간을 빈 줄 하나로 잇고, 그 텍스트와 버전에 달린 노트 전부를 markNotes에 넘겨. 웹 쪽은 frontend/src/momentState.ts에 있고(두 번째 코드 블록), 폰의 Swift 쌍둥이는 MomentState.swift에 있어. 폰 쪽 문서 주석이 웹 함수를 이름으로 가리켜. 함수는 인용마다 자리를 찾아 범위 목록을 만들고, 텍스트를 표시 없는 조각과 표시된 조각으로 잘라. 정확히 같은 대목을 가리키는 노트 둘은 한 범위를 같이 써. 이미 잡힌 범위와 겹치는 노트는 따로 표시를 못 받지만, 리뷰 목록엔 그대로 남아. 결과는 어떤 렌더러든 그릴 수 있는 납작한 조각 목록이야. 웹은 표시된 조각을 <mark> 요소로 감싸고 코멘트를 툴팁으로 달아. 폰은 속성 문자열에 색을 칠해. suspect 노트가 있는 조각은 빨갛게, naturalness 노트는 호박색으로 남아.
고쳐 쓰기: 같은 자리가 다시 써
노트가 다 들어오면 아빠는 노트를 반영해 고쳐 쓰는 Revise with notes 버튼을 누를 수 있어. 라우트 POST /api/moments/{id}/versions/{turn}/revise는 버전을 아직 쓰는 중이면 409로 답해. 그렇지 않으면 이전 버전을 베껴 새 version 턴을 열어. 같은 브레인, 노력 수준, 모델, 같은 용도와 언어, 같은 작업과 자리 번호, 같은 리뷰어 목록, 그리고 거꾸로 가리키는 revises_turn. 버전에 노트가 없을 때, 그리고 그 버전의 리뷰나 다른 리비전이 돌고 있을 때는 두 클라이언트 모두 이 버튼을 꺼둬.
그러면 run_version은 레슨 2와 똑같이 돌아. 구간마다 입력이 두 개 더 붙을 뿐이야. 레슨 2에서 본 코드가 모아 둔 것들인데, 이전 버전의 그 구간 번역과, 리뷰어가 이전 버전의 그 구간에 남긴 노트 전부야. 세 번째 코드 블록이 version_prompt가 이걸 어떻게 늘어놓는지 보여줘. 노트에 붙는 지시가 이 트랙의 핵심이야. 옳게 짚은 건 고치고, 뜻에 대해 틀린 노트라면 원문이 말하는 대로 지켜.
이 문장이 레슨 4가 연 고리를 닫아. 리뷰어는 원문 없이 대목을 짚었고, 쓰는 쪽은 원문을 손에 쥐고 그걸 판단해. suspect 노트가 맞을 수도 있어. 그럼 쓰는 쪽이 그 대목을 고쳐. 틀릴 수도 있어. 원문이 이상하면 충실한 번역도 이상하게 읽히니까. 그땐 원문이 이겨.
덮어쓰는 건 없어
리비전은 옛 버전 옆에 서는 새 버전이야. 이전 턴과 그 구간들은 그대로 남아. 리비전 테스트는 구간을 가진 턴이 이전 턴과 리비전, 이렇게 둘인지 확인해. 리비전엔 두 클라이언트 모두 revision 칩이 붙고, 리뷰어 목록을 베껴 왔으니 끝나면 자기 콜드 리뷰도 따라와. 리비전을 또 고쳐 쓸 수도 있어. 모든 단계가 순간 기록 안에 나란히, 읽을 수 있게 남아.
계보는 자리 옆에, 절대 위엔 안 써
필드 하나를 어떻게 두느냐가 리비전을 정직하게 지켜. 버전이 끝나면 run_version은 마지막 봉투에서 answered_model과 answered_effort를 꺼내서 자리에 적힌 model, effort 옆에 기록해. 그 위에 덮어쓰진 않아. 주석이 이유를 둘 대. 자리에 적힌 모델은 비어 있을 때가 많고, 그건 베슬 기본값이라는 뜻이야. 리비전이 다시 요청해야 하는 게 바로 그거야. 그리고 베슬이 보고하는 모델 id가 늘 그 베슬이 요청으로 다시 받아주는 값은 아니야.
리비전 테스트가 두 쪽을 다 확인해. 리비전 호출은 model이 여전히 None인 채로 나가고, 이전 턴은 model이 None이면서 그 옆 answered_model은 m-1이야. 웹은 자리 이름 옆에 답한 모델을 보여줘. 그래서 아빠는 뭐가 돌았는지 보고, 기록은 아빠가 뭘 골랐는지 잊지 않아.
원칙: 요청한 것과 답한 것은 필드를 나눠 둬. 다시 할 땐 요청한 걸로 다시 요청해. 리뷰어가 원문을 못 본다면, 뜻에 대한 마지막 말은 원문을 보는 쓰는 쪽에 있어.
같은 이야기를 저자 쪽에서 들려주는 게 Inkwell Quest야. "리뷰어는 펜을 쥐지 않아"와 "저자는 stet할 수 있어" 레슨을 봐.
Code
cwkSpark, backend/versions.py: 인용과 앵커 표시를 단 노트, 그리고 구간마다 판정 (원문 그대로, 413-441행)·python
found = 0
for obj in _review_objects(str(envelope.get("result") or "")):
kind = str(obj.get("type") or "")
if kind == "note" and str(obj.get("quote") or "").strip() and str(obj.get("comment") or "").strip():
quote = str(obj["quote"]).strip()
await self._write( # type: ignore[attr-defined]
self.corpus.add_result, # type: ignore[attr-defined]
moment["id"],
turn_id=turn["id"],
target_language=language,
fields={
"kind": "note",
"n": n,
"about": version_id,
"quote": quote,
"anchored": quote in text,
"note_kind": "suspect" if obj.get("kind") == "suspect" else "naturalness",
"comment": str(obj["comment"]).strip(),
},
)
found += 1
elif kind == "verdict" and str(obj.get("note") or "").strip():
await self._write( # type: ignore[attr-defined]
self.corpus.add_result, # type: ignore[attr-defined]
moment["id"],
turn_id=turn["id"],
target_language=language,
fields={"kind": "verdict", "n": n, "about": version_id, "note": str(obj["note"]).strip()},
)
cwkSpark, frontend/src/momentState.ts: 버전 텍스트를 표시 없는 조각과 표시된 조각으로 자르기 (원문 그대로, 225-254행)·typescript
/** A version's text cut into plain runs and runs a note quotes, so each
* anchored note can be marked where it points. Overlapping quotes keep the
* earlier one; a quote not found stays in the list without a mark. */
export function markNotes(text: string, notes: Result[]): { text: string; notes: Result[] }[] {
const spans: { start: number; end: number; notes: Result[] }[] = [];
for (const note of notes) {
const quote = note.quote ?? "";
if (!quote) continue;
const start = text.indexOf(quote);
if (start < 0) continue;
const end = start + quote.length;
const same = spans.find((span) => span.start === start && span.end === end);
if (same) {
same.notes.push(note);
continue;
}
if (spans.some((span) => start < span.end && end > span.start)) continue;
spans.push({ start, end, notes: [note] });
}
spans.sort((a, b) => a.start - b.start);
const out: { text: string; notes: Result[] }[] = [];
let at = 0;
for (const span of spans) {
if (span.start > at) out.push({ text: text.slice(at, span.start), notes: [] });
out.push({ text: text.slice(span.start, span.end), notes: span.notes });
at = span.end;
}
if (at < text.length) out.push({ text: text.slice(at), notes: [] });
return out;
}
cwkSpark, backend/versions.py: 구간 프롬프트. 리비전이면 이전 버전과 노트가 같이 들어가 (원문 그대로, 106-135행)·python
def version_prompt(
n: int,
total: int,
section: str,
*,
glossary: list[tuple[str, str]],
previous: tuple[str, str] | None,
earlier: str | None = None,
notes: list[dict[str, Any]] | None = None,
) -> str:
parts = [f"Section {n} of {total}."]
if glossary:
parts.append("Glossary so far (binding):\n" + "\n".join(f"- {s} → {t}" for s, t in glossary))
if previous is not None:
parts.append(
"The section before this one, for continuity (do not translate it again):\n"
f"<source>\n{previous[0]}\n</source>\n<translation>\n{previous[1]}\n</translation>"
)
if earlier is not None:
parts.append(f"Your earlier version of this section:\n<translation>\n{earlier}\n</translation>")
if notes:
lines = "\n".join(
f"- [{note.get('note_kind') or 'note'}] “{note.get('quote')}”: {note.get('comment')}" for note in notes
)
parts.append(
"Notes from reviewers who read only the earlier version, never the source. Fix what they rightly "
"flag; where a note is wrong about the meaning, keep what the source says:\n" + lines
)
parts.append(f"Translate this section:\n<source>\n{section}\n</source>")
return "\n\n".join(parts)
만들고 있는 문서 편집기나 생성 파이프라인에 인용으로 자리를 짚는 리뷰 노트와 리비전을 붙여봐. 노트마다 정확한 인용, 속한 구간, 단순한 부분 문자열 검사로 정한 앵커 표시를 저장해. 텍스트는 표시 없는 조각과 표시된 조각으로 그리고, 대목 하나엔 범위 하나만 두고, 겹치는 노트는 목록엔 남기되 표시는 하지 마. 이어서 같은 설정으로 새 버전을 만드는 고쳐 쓰기 동작을 붙여. 이전 텍스트와 그 노트를 쓰는 쪽에 넘기고, 이전 버전은 손대지 않게 해.
Hint
인용은 노트가 가리키는 구간 안에서만 찾아. 그래야 되풀이되는 표현이 리뷰어가 뜻한 자리에 표시돼. 요청한 모델과 답한 모델은 필드 두 개로 저장하고, 고쳐 쓰기 호출이 요청한 쪽을 보내는지 시험해. 마지막으로, 리비전 뒤에도 두 버전이 자기 텍스트를 가진 채 다 남아 있는지 확인해.
Progress
Progress is local-only — sign in to sync across devices.