リポジトリ内のマークダウンをLLMでまとめて自動翻訳するGithubActionsを作った
作ったもの
リポジトリ内のすべてのMarkdownファイルをGlobパターンで検索し、それぞれClaude3.5を使用して翻訳して保存するスクリプトとGitHub Actionsを作成しました。オリジナルのドキュメントをForkし、その中にこれらのスクリプトを組み込むことで、オリジナルのドキュメントが更新されるたびにSyncForkボタンを押すだけで、Actionsが自動的に翻訳を実行してくれます。
現在は、Nostrの仕様書であるNIPs、およびEthereum関連のEIPsやERCsの翻訳を行っています。実際に翻訳されたドキュメントは以下のリンクから確認できます。
動機
僕は英語が得意ではなく、時間をかけて読むならまだしも、流し読みはとても苦手です。しかし、僕が熱中しているNostr(分散型SNSプロトコル)の仕様書はすべて英語のMarkdownで書かれており、さっと全体を把握するには言語の壁が立ちふさがりました。
有志による翻訳ドキュメントも存在していましたが、一部は未翻訳であったり、最新版に追いついていなかったりしました。そのため、結局は原文をDeepLやClaudeに投入して翻訳することが多く、二度手間になっていました。
どうせ全部読むなら、あらかじめ高精度なLLMでまとめて翻訳すればいいんじゃね?と思い、今回のActionsを作成しました。
コスト
ドキュメント全体を翻訳すると莫大なコストが掛かる印象がありますが、存外大した事ありません。実際にNIPsをClaude3.5 Sonnetで翻訳したところ、5~6ドルぐらいで済んでいます。ビッグマックセット1個分ぐらいですね。ERCsやEIPsは分量が多いのでより安価なClaude3 Haikuで翻訳したのですが、合計で10ドルぐらいでした。
また、Claude3.5の方が精度が高そうな気がしたので使いませんでしたが、Gemini系の場合無料枠が非常に大きいので、無料枠内で翻訳しきれるかもしれません。
大雑把な仕組み
このツールは大きく分けると、GitHub ActionsのWorkflowとTypeScriptのスクリプトという2つのパーツで動いています。リポジトリ内のMarkdownに変更があると、GitHub Actionsが発火して、DenoでTypeScriptのスクリプトを実行し、結果を保存するという流れです。
ドキュメント等未整備で作りっぱなしですが、Githubで全体を公開しています。
翻訳スクリプトは、サクッと動かしたかったのでDeno用のTypeScriptで書きました。今回ほぼ初めてDenoを触ったんですが、GlobやENV周りが標準パッケージとして使えて便利でした。(ENV系は最近Node.jsにも組み込まれたようですが。)
スクリプトでちょっと工夫した点として、ハッシュによる変更検知があります。LLMが安くなったとはいえ、ドキュメントが変更されるたびに全体を翻訳すると、効率も悪く財布にも穴が開きます。そこで、このスクリプトでは翻訳後のドキュメントの上部に、こんな感じでyaml形式でオリジナルのドキュメントのsha256ハッシュを埋め込んでいます。
---
original: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
---
これで、前回翻訳したときから変更があったドキュメントだけを翻訳できるようになりました。
本当はGitのDiffを取って、差分だけを翻訳した方が文章の一貫性や更なるコスト削減ができると思うんですが、開発コストが膨らみそうだったので今回は見送りました。
また、Claude 3.5のOutput Tokenは最大で4096トークンなので、長めのドキュメントだと一度で翻訳しきれないことがあります。そこで、Claude 3.5の作法とパースのしやすさを考えて、翻訳文章を<translated_document>というXMLタグで囲って出力させるようにしました。閉じタグの</translated_document>がLLMの出力に含まれていなかったら、まだ翻訳が終わっていないと判断して、ループで翻訳を続けるようにしています。
Claude 3.5のContext Windowは結構大きくて、割と正確に読み取ってくれるので、こんな感じの雑なコードでループを回しつつ、LLMの出力のブレをケアしてあげれば、Output Tokenからはみ出る長さでもいい感じに処理できるんです。
let result = "";
const messages = [{ role: "user", content: prompt }];
for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await client.messages.create({
model: MODEL_NAME,
max_tokens: MAX_OUTPUT_TOKEN,
messages,
temperature: 0,
});
const content = response.content[0].text as string;
const translated = content.includes("<translated_document>")
? content
.split("<translated_document>")[1]
.split("</translated_document>")[0].trim()
: content
.split("</translated_document>")[0].trim();
const lastline = result.split("\n").slice(-1)[0];
if (translated.startsWith(lastline)) {
result = result.slice(0, -lastline.length) + "\n" + translated;
} else {
result += "\n" + translated;
}
if (content.includes("</translated_document>")) {
break;
}
messages.push({ role: "assistant", content });
messages.push({
role: "user",
content: "continue with <translated_document>",
});
}
スクリプトの実行が終わった後は、GitHub Actionsで差分をPushします。今回初めて知ったんですが、Workflow内はGITHUB_TOKENやGITHUB_REPOSITORYが標準で読み取れるようになっているんですね。それを使って、こんな感じのコードでPushを実現しました。
- name: push changes
run: |
git remote set-url origin https://github-actions:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}
git config --global user.name "${GITHUB_ACTOR}"
git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com"
if (git diff --shortstat | grep '[0-9]'); then \
git add .; \
git commit -m "Push Translation"; \
git push origin HEAD:${GITHUB_REF}; \
fi
終わりに
こんな感じで翻訳ツールを作ってみました。ここ数日は実際に翻訳されたNIPsを参照しながらNostrのリレーを実装したりと、少なくとも僕の役には立っています。今回翻訳したドキュメントや、この記事に関する知見が誰かの役に立てば幸いです。
Discussion