LLM 작업실
원문을 다시 읽는 번역 워커
번역 엔진을 Kimi에서 Antigravity로 옮기면서 원문대조 폴리싱 패스를 세우고, 그 김에 한글 원문의 오류까지 같이 훑어 대시보드로 올리게 한 일
관련 파일: scripts/, scripts/, _config.yml, 4d2ebc7c, 20cc224b
EN 코퍼스는 전부 Kimi K3가 번역한 것이다. 그 번역을 다시 훑는 패스를 붙이기로 하면서, 사용자는 같은 엔진으로 자기 결과를 검토시키는 구도를 피했다.
근데 이제 번역도 원래 Kimi로 translate가 다 됐었잖아. 근데 이제 그거를 또 똑같이 Kimi로 polish 하는 것보다는 다른 걸로 하는 게 나을 것 같아서, Antigravity로 올렸어. 그리고 올리면서 한글 글에 오류가 있는 것도 걔가 같이 보게 했고, 그리고 겸사겸사 영문과 한글 글의 불일치가 있으면 그것도 하게 했단 말이야.
같은 자리에서 주기도 바뀌었다. 8월 전수 감사가 지적을 한꺼번에 쏟아내는 바람에 사용자가 끝내 따라가지 못했고, 그 경험에서 하루 6편이 실제로 소화되는 양이라는 계산이 나왔다. 30분마다 돌던 것을 4시간에 한 번으로 늘린 것은 그 숫자에 맞춘 결과다. 워커가 느려서가 아니라 사람이 읽을 수 있는 속도가 상한이다.
엔진 이름을 _config.yml로 끌어올리기
기계번역 고지는 EN frontmatter의 translation_source가 _config.yml의 정본 태그와 일치할 때만 붙는다. 태그를 kimi-cli에서 갈아치우면 기존 EN 400여 편이 전부 고지를 잃는다. 그래서 정본 하나가 아니라 세 개의 키로 나눴다.
translation_source_tag : "antigravity-gemini-3.8-flash-high"
translation_source_legacy_tags : ["kimi-cli"]
translation_polish_source_tag : "antigravity-gemini-3.8-flash-high"
legacy는 “더 이상 새로 쓰지 않지만 계속 표시할 엔진”이다. 고지 include는 현재 태그와 legacy 목록을 둘 다 본다.
{% assign _machine_translation = false %}
{% if page.translation_source == site.translation_source_tag %}
{% assign _machine_translation = true %}
{% elsif site.translation_source_legacy_tags contains page.translation_source %}
{% assign _machine_translation = true %}
{% endif %}
_data/의 키 이름은 kimi_translation_notice 그대로 뒀다. 엔진 이름이 키에 들어가 있는 것이 이제 와서 어색하지만, 이름을 바꾸면 include와 두 언어 블록을 동시에 고쳐야 하고 얻는 것이 없다.
워커 쪽은 백엔드 이름에서 태그를 역산한다. 롤백 경로를 남겨 둔 것이라, TRANSLATOR_BACKEND=kimi로 되돌리면 태그도 따라 돌아간다.
_BACKEND_SOURCE_TAGS = {
"antigravity": _CONFIG_TRANSLATION_SOURCE_TAG,
"kimi": "kimi-cli",
"glm": "glm-cli",
}
_load_translation_source_tags()는 _config.yml에서 세 키를 읽고, 현재 태그나 폴리싱 태그가 비어 있으면 그냥 죽는다. 고지 include와 짝이 되는 값이라 조용히 빈 문자열로 넘어가면 사이트 전체에서 고지가 사라진다.
폴리싱 패스를 태그로 세우기
폴리싱은 별도 큐가 아니라 frontmatter 태그 하나로 굴러간다. EN에 translation_polish_source가 현재 폴리싱 태그와 같으면 이미 이번 패스를 거친 것이고, 아니면 대상이다.
meta = en_translation_meta(existing_en)
if meta.get("translation_polish_source") == TRANSLATION_POLISH_SOURCE_TAG:
continue # already polished by the current pass
return ko, existing_en, "polish"
그다음 Phase 4의 검증은 조건이 정확히 반대다.
if meta.get("translation_polish_source") != TRANSLATION_POLISH_SOURCE_TAG:
continue # current contrastive pass comes first
두 조건이 서로의 여집합이라 순서가 저절로 강제된다. 폴리싱 안 된 글은 Phase 3이 가져가고, 폴리싱된 글만 Phase 4가 검증한다. 나중에 엔진을 또 바꾸면 translation_polish_source_tag만 새 값으로 두면 되고, 코퍼스 전체가 자동으로 다시 폴리싱 대기열에 들어간다. 태그를 큐로 쓰는 이유가 이것이다.
frontmatter는 본문과 분리해서 처리한다. _polish_fm_fields가 KO/EN 필드를 JSON으로 주고받고, 본문 프롬프트는 frontmatter를 아예 보지 않는다. 프롬프트에 “Output ONLY the complete repaired English body”라고 적힌 것은 그래서다. 본문 폴리싱이 title: 줄을 건드리면 permalink와 사이드바가 같이 흔들린다.
폴리싱 중에 한글 원문을 함께 읽기
같은 호출에서 한글 원문의 오류도 보게 했다. 프롬프트는 이걸 read-only 분류 작업으로 규정한다.
Read the complete Korean mathematics post below during this polishing run.
This is a read-only triage task: never rewrite the post and never discuss
the English translation.
프롬프트로 부탁하는 것만으로는 부족해서 해시로 확인한다. 후보를 받아 Codex 2차 판정을 붙이는 review_ko_findings가 전후 SHA-256을 비교하고, 다르면 알림을 띄운다.
if hashlib.sha256(ko_path.read_bytes()).hexdigest() != before:
log(f"GATE-KO-REVIEW ({key}): KO 파일이 변경됨 — read-only 계약 위반")
_notify("[translate-worker] Codex KO 검토가 원문을 수정함",
f"{key}\nCodex 검토 전용 단계 전후의 KO 해시가 다릅니다.",
level="timeSensitive")
return out
Codex 판정은 VALID·FALSE·UNSURE 셋 중 하나만 받는다. 호출이 실패하거나 응답이 파싱되지 않은 후보는 판정 없이 그대로 남긴다. 검토가 안 됐다는 이유로 후보를 숨기면 사용자 알림에서 조용히 사라지고, 그건 오류가 없다는 뜻으로 읽힌다.
후보에는 원문의 인용구가 딸려 오는데, 그 인용구가 KO 본문에서 안 찾아지면 후보를 버린다.
pos = ko_text.find(quote)
# A finding without an exact source locus cannot provide a trustworthy
# line number or later diff target.
if pos < 0:
continue
인용구를 못 찾는 후보는 위치를 알려줄 수 없고, 나중에 수정분을 대조할 기준도 못 준다. 모델이 원문을 살짝 바꿔 옮긴 것과 없는 문장을 지어낸 것을 여기서 구분할 방법은 없으니 둘 다 버린다.
찾아진 후보는 그 시점의 KO 전문을 상태 파일에 통째로 남긴다.
ko_review_base = ko_path.read_text(encoding="utf-8")
state["files"][key]["ko_review_base_content"] = ko_review_base
state["files"][key]["ko_review_base_sha256"] = hashlib.sha256(
ko_review_base.encode("utf-8")).hexdigest()
git에서 꺼내면 될 것 같지만 안 된다. 감사 시점의 KO가 아직 커밋 안 된 워킹트리 상태일 수 있고, 그러면 재구성할 방법이 없다. 후보가 살아 있는 동안만 들고 있다가 해소되면 버린다.
\times가 탭이 되는 자리
모델 응답을 JSON으로 받다 보면 수식이 문제가 된다. 유효한 JSON 안에 LaTeX 백슬래시가 한 개짜리로 들어오면 json.loads가 그걸 이스케이프로 읽는다. \times는 예외 없이 탭 문자 하나에 imes가 붙은 문자열이 되고, 파싱은 성공한다. 깨진 채로 통과하는 쪽이 실패하는 쪽보다 나쁘다.
def protect_math(m: re.Match) -> str:
return re.sub(r'(?<!\\)\\(?![\\"])', r'\\\\', m.group(0))
t = re.sub(r"\$\$.*?\$\$|\$[^$\n]*\$", protect_math, t, flags=re.DOTALL)
수식 구간 안에서만 홑백슬래시를 이중으로 만든다. 뒤에 백슬래시나 따옴표가 오는 경우는 건드리지 않는데, 그건 모델이 제대로 이스케이프한 것이라 손대면 거꾸로 깨진다. 수식 밖의 JSON 이스케이프는 원래 의미를 유지한다.
대시보드 체크박스는 승인이 아니라 요청
한글 오류를 고치면 EN도 따라가야 한다. ko_followup_worker.가 그 일을 맡는데, 대시보드 체크박스의 의미를 정한 것이 설계의 전부다.
"""Follow up dashboard-confirmed Korean fixes and synchronize the English post.
The dashboard checkbox is a request, not an acknowledgement. One request is
handled per run: Antigravity proposes the narrowly scoped EN replacement, then
Codex sees only the original finding plus KO/EN unified diffs and decides whether
both changes implement that finding. The queue item is removed only after a
passing check and a successful content commit.
"""
체크를 승인으로 읽으면 체크하는 순간 항목이 큐에서 빠지고, EN 동기화가 실패해도 아무도 모른다. 요청으로 읽으면 항목은 EN 커밋이 실제로 들어갈 때까지 남는다.
판정하는 Codex에게는 원래 지적과 KO/EN 두 diff만 준다. 글 전문을 주면 지적과 무관한 개선안을 함께 들고 오고, 그러면 판정이 “이 지적을 구현했는가”가 아니라 “이 글이 좋아졌는가”가 된다. 제안하는 쪽과 판정하는 쪽에 다른 모델을 놓은 것도 같은 이유다.
follow-up은 별도 크론이고, 번역 워커와 2시간씩 엇갈리게 걸려 있다.
15 */4 * * * … translate_worker.py
15 2-22/4 * * * … ko_followup_worker.py
앞의 것이 0·4·8·12·16·20시에 한 편을 폴리싱하고, 뒤의 것이 2·6·10·14·18·22시에 체크된 요청 하나를 처리한다. 폴리싱이 올린 한글 지적을 사용자가 대시보드에서 체크하면 다음 짝수 시각에 EN이 따라온다. 같은 시각에 두 워커가 같은 글을 두고 부딪히는 일도 없다.
결국 2시간마다 뭔가가 한 편씩 도는데, 그 사이에 사람이 읽어주지 않으면 다음 틱은 의미가 없다. 주기를 정한 것도 그쪽이었다.
폴리싱도 출력 한도에 걸리다
번역 쪽 조각내기는 진작에 있었다. KO 본문이 길면 ::: 정리 박스 경계로 잘라 따로 호출하는 _split_regions/translate_body_chunked가 통짜 번역이 출력 한도에서 끊기는 문제를 이미 막아 왔다. 폴리싱은 그 경로를 안 탔다. build_polish_prompt로 KO/EN을 통째로 한 번에 넘겼는데, CA/Differentials 글에서 KO 22,793자를 그대로 보냈다가 안티그래비티가 출력 토큰 한도에서 잘렸다. 폴리싱 출력은 EN 전문이라 번역과 같은 크기 문제를 그대로 물려받은 것인데, 조각내기 코드는 없었다.
기존 _group_regions를 그대로 쓸 수는 없었다. 그 함수는 KO 리전 하나의 길이만 보고 묶는데, 폴리싱은 KO와 EN을 짝지어 같이 보내야 하고 조각 경계도 두 쪽에서 동일해야 한다. _group_region_pairs는 box id로 KO/EN 리전을 짝짓고, 각 짝의 길이는 둘 중 긴 쪽으로 잰다.
span = max(len(ko_text), len(en_text))
if cur_ko and cur_len + span > max_chars:
batches.append(("".join(cur_ko), "".join(cur_en)))
cur_ko, cur_en, cur_len = [], [], 0
KO만 보면 안 되는 이유는 폴리싱 출력이 EN 길이에 가깝기 때문이다. 번역이 짧게 요약된 문단이 있으면 KO는 짧은데 EN이 길어, KO 기준으로만 자르면 그 조각에서 다시 출력 한도에 걸릴 수 있다.
id 열이 KO와 EN에서 어긋나면(_split_regions가 뽑은 순서가 다르면) _group_region_pairs는 None을 돌려주고, 호출부는 조각내지 않은 통짜 폴리싱으로 되돌아간다. 정상 경로에서는 lint_structure를 통과한 글만 폴리싱까지 오므로 박스 수가 같고 어긋나지 않지만, 어긋난 경우를 조용히 조각내면 짝 없는 텍스트를 모델이 지어내거나 지운다.
조각마다 붙는 프롬프트도 번역 쪽과 같은 모양이다. 전체 중 몇 번째 조각인지 적고, 도입부·요약·전환 문장을 만들지 말고 분할 사실도 언급하지 말라고 명시한다. 다만 폴리싱은 라벨 번호를 새로 매기는 게 아니라 기존 번호를 그대로 지키는 일이라, 주의사항도 “라벨 번호는 조각을 넘어 이어지니 보이는 대로 유지하라”로 바뀐다.
경계값 자체도 두 번 내려갔다. 처음에는 번역 쪽 24,000자/12,000자 기준을 그대로 물려받아 10,000자/6,000자로 낮췄는데, 그 사흘 뒤 4,000자/4,000자로 한 번 더 낮아졌다. 판정 기준도 바뀌었다. 처음엔 KO 길이만 봤지만, 폴리싱 출력이 EN 쪽에 가깝다는 점을 감안해 KO와 EN 중 긴 쪽으로 조각 여부를 판정하게 됐다.
chunked = (polish_body_chunked(ko_body, en_current_body)
if max(len(ko_body), len(en_current_body)) > FULL_CHUNK_THRESHOLD
else None)
번역과 폴리싱이 같은 상수(FULL_CHUNK_THRESHOLD, MAX_CHUNK_CHARS)를 공유하므로, 이 값을 내리면 번역 쪽 조각도 더 잘게 쪼개진다. 번역은 출력이 EN 하나뿐이라 원래도 여유가 있었지만, 두 경로를 하나의 상수로 묶어 둔 대가로 폴리싱이 요구하는 보수적인 값을 함께 물려받았다.
후속 요청이 거절되기까지의 세 관문
체크박스 절을 쓴 뒤로 후속 워커는 몇 번 더 바뀌었다. 먼저 “한 번에 요청 하나”가 없어졌다. 지금은 실행이 시작될 때 큐에 있는 요청을 전부 순차 처리하고, 각 항목은 실행당 한 번만 시도한다. 한 항목이 대기로 남아도 뒤의 요청은 막히지 않는다.
f5bf0549는 판정 쪽의 두 가지 헛돌기를 막았다. 하나는 EN diff가 비었을 때였다. 판정자는 diff만 받았으므로 빈 EN diff를 보면 “EN이 고쳐지지 않았다”고 거절했는데, 실제로는 EN이 이미 올바른 뜻을 담고 있어 고칠 것이 없는 경우가 있었다. 이제 판정자는 최종 EN 전문(FINAL EN)을 같이 받고, 빈 diff일 때는 그 본문이 고친 한글의 뜻을 이미 담고 있는지를 직접 본다. 다른 하나는 첫 수정안이 거절될 때였다. 거절 사유는 대개 구체적이어서(“이 문장의 계수도 바꿔야 한다”), 그대로 제안자에게 돌려주면 고칠 수 있었다.
for attempt in range(1, MAX_PROPOSAL_ATTEMPTS + 1):
candidate = antigravity_candidate(
current_en, findings, ko_diff, review_feedback=feedback,
author_note=author_note,
)
...
passed, why = codex_pass(findings, ko_diff, en_diff, candidate, author_note)
if passed:
return candidate, True, why
feedback = why
MAX_PROPOSAL_ATTEMPTS는 2다. 두 번째도 거절되면 그때 사용자 요청을 거절 처리하고, 체크를 풀고, 사유를 대시보드 행에 남긴다.
세 번째 관문은 KO diff의 범위다(6bfbe498). 사용자가 한 글에서 지적 하나를 고치는 김에 다른 문단도 손보거나, 분류기가 같은 글의 다른 링크에 data-relation을 달아 두면, 감사 시점 원문과 현재 원문의 diff에 그것들이 다 섞였다. 판정자는 그 무관한 변경을 보고 거절하기도 했다. _scoped_ko_diff는 각 지적의 인용문이 원문에서 차지하는 줄 범위를 구하고, difflib.의 변경 덩어리 중 그 범위의 앞뒤 두 줄 안에 걸치는 것만 새 판본으로 두고 나머지는 옛 판본으로 되돌린 뒤 diff를 다시 뜬다. 판정 프롬프트에도 “다른 링크의 data-relation은 별도 워크플로의 소유”라고 적었다.
지적을 읽는 화면과 모델에게 남기는 말
폴리싱 한 번이 한글 지적을 서너 개씩 올리니, 그것을 읽는 쪽의 부담이 늘었다. 사용자가 짚은 곳은 두 군데였다.
지금 번역워커가 돌면 원문 지적 로그가 뜨잖아, 그거 가독성 좀 개선하자.
대시보드에서 클릭하면 나오는 로그도 잘 보이게 바꿔줘. 그게 핵심이야.
폰 알림에는 원래 [VALID] [ERROR] 한글 md line 90:으로 시작해 긴 인용, 지적, (제안: …)을 한 줄에 이어 붙인 문자열 뒤에 → 이유와 수정안:이 또 붙었다. 대시보드와 후속 워커가 비교 키로 쓰는 claim 문자열을 그대로 찍은 탓에 수정안이 두 번 나왔고, 뒤에는 safe verdict 전문까지 딸려 와서 Bark의 3000자 상한에서 잘리기 일쑤였다. 6e23d63c는 claim은 그대로 두고 알림 쪽 렌더러만 새로 썼다. 항목마다 L90 · VALID 한 줄, 인용 한 줄(160자를 넘으면 가운데를 줄인다. 틀린 곳이 문장 끝에 있는 경우가 많아서다), 문제, 수정 순이다. VALID는 Codex가 확정한 이유만 보이고, UNSURE와 미검토는 원 지적과 Codex 의견을 나란히 둔다. 잘려도 되는 것(경고, verdict 전문)은 뒤로 보냈다.
대시보드 모달은 모노스페이스 <pre>에 같은 한 줄을 쏟던 것을 구조화된 목록으로 바꿨다. 이를 위해 서버가 지적의 line·quote·issue·suggested_fix를 따로 내보낸다. 수식 렌더도 사용자가 요청했다.
문제랑 수정도 수식 렌더가 되면 좋겠어.
대시보드는 블로그와 같은 오리진에 있으므로 /assets/katex/와 katex-macros.js를 그대로 불러 모달에 auto-render를 건다. 걸리는 것은 모델이 가끔 $ 없이 LaTeX를 쓴다는 점이었다(\alpha\smile\beta를 …). 두 쪽에서 막았다. 워커의 Codex 프롬프트는 수식을 반드시 $…$로 쓰라고 요구하고, 이미 저장된 항목은 대시보드의 wrapBareTex가 감싼다. 이 함수는 기존 $ 구간을 먼저 쪼개 건드리지 않고, 남은 텍스트에서 \명령을 포함하는 ASCII 수식 문자 연속을 찾아 한글·끝 구두점·공백에서 끊은 뒤 중괄호 균형이 맞을 때만 $로 싼다.
마지막은 반대 방향의 통로다.
그 과정에서, 내가 모델에게 전할 말이 있으면 넣을 방법이 없어. 그 기능 좀 추가해줘.
모달 아래에 메모 칸이 생겼다. 메모는 체크 파일과 따로 ~/.local/에 요청 키(<ko-) 단위로 병합 저장된다. 체크 map은 클라이언트가 통째로 교체하는 방식이라, 거기 섞으면 다른 탭이 메모를 날린다. 후속 워커는 메모를 제안 프롬프트와 판정 프롬프트 양쪽에 AUTHOR NOTE로 넣는다. 판정 쪽에는 이것이 작성자의 설명이지 명령이 아니라고 적었다. 작성자가 “이 지적은 오탐이라 고치지 않았다”고 하면 그 근거가 수학적으로 타당하고 글이 뒷받침할 때만 통과시키고, 판정 이유에서 메모에 답하게 했다.
메모가 있으면 지적 위치의 KO가 바뀌지 않았어도 요청이 판정으로 넘어간다. 원래 이런 요청은 “지적 위치의 KO 변경이 없음”으로 영영 대기에 머물렀다. 오탐을 해명할 길이 체크 해제 말고는 없었던 셈이다. 승인되면 메모도 지워지고, 거절되면 남아서 고쳐 쓸 수 있다. 재감사로 키가 바뀌었거나 지적이 사라진 메모는 매 실행 첫머리에 정리한다. 이제 사용자와 판정자 사이에 대화 비슷한 것이 생겼다. 한 턴에 4시간이 걸리는 대화이긴 하다.
공유하기
Twitter Facebook LinkedIn댓글
- 공개 저장소입니다. 댓글을 지워도 Git 히스토리에는 남습니다.
- 암호와 이메일은 암호화해 Cloudflare KV에만 보관합니다.
아직 댓글이 없습니다.