2026年2月21日土曜日

ローカルLLMで心理学論文のPDFを翻訳するPythonスクリプトを作った

まあChatGPTとGeminiに書いてもらったんですが。

大阪出張のときに参加したOSC2026 Osakaいくやさんのセミナーに刺激されて、がんばって作ってみました。ChatGPTが書くコードそのまんまだとあまりうまくいかなかったので、Geminiにいろいろ直してもらいました。僕は1行もコードを書いていません。すげえ時代だ……。

こんな感じでセットアップします(Ubuntu 24.04.4 LTS; CUDAセットアップ済み):

(1) llama.cppをビルド。
(2) grapevine-AI/plamo-2-translate-gguf~/AI/models/plamo/にダウンロード。当環境ではQ8_0版がいい感じ。
(3) 仮想環境をつくる:

$ mkdir -p ~/AI/pdf_translate
$ uv venv
$ source .venv/bin/activate

(4) MinerUとrequestsをインストール:

$ uv pip install -U "mineru[all]" requests

(5) vim pdf2jp.py して次のように書く:

#!/usr/bin/env python3
import argparse
import subprocess
import sys
import time
import requests
import hashlib
import sqlite3
import shutil
import re
from pathlib import Path

# =====================
# 設定
# =====================
LLAMA_PORT = "8080"
LLAMA_URL = f"http://127.0.0.1:{LLAMA_PORT}/completion"
LLAMA_BIN = str(Path.home() / "llama.cpp/build/bin/llama-server")
LLAMA_MODEL = str(Path.home() / "AI/models/plamo/Plamo-2-Translate-Q8_0.gguf")

LLAMA_ARGS = [
    LLAMA_BIN,
    "-m", LLAMA_MODEL,
    "-ngl", "99",
    "--threads", "8",
    "--parallel", "1",
    "--ctx-size", "8192",
    "--port", LLAMA_PORT,
    "--flash-attn", "on",
    "--repeat-penalty", "1.0", # 繰り返しペナルティなし(専門用語用)
    "--no-mmap"  # メモリ読み込み安定化
]

CACHE_DB = "pdf2jp_cache.db"
MAX_WORDS = 100   # 1回の翻訳単位(単語数)
N_PREDICT = 1024  # 生成トークン数(512だと稀に切れるので1024に統一)
STARTUP_TIMEOUT = 120
REQUEST_TIMEOUT = 600

# =====================
# キャッシュ
# =====================
class Cache:
    def __init__(self):
        self.conn = sqlite3.connect(CACHE_DB)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS cache(
                hash TEXT PRIMARY KEY,
                src TEXT,
                dst TEXT
            )
        """)

    def key(self, text):
        return hashlib.sha256(text.encode()).hexdigest()

    def get(self, text):
        k = self.key(text)
        cur = self.conn.execute("SELECT dst FROM cache WHERE hash=?", (k,))
        r = cur.fetchone()
        return r[0] if r else None

    def set(self, text, out):
        k = self.key(text)
        self.conn.execute(
            "INSERT OR REPLACE INTO cache VALUES(?,?,?)",
            (k, text, out)
        )
        self.conn.commit()

cache = Cache()

# =====================
# ユーティリティ
# =====================
def is_llama_alive():
    try:
        r = requests.get(f"http://127.0.0.1:{LLAMA_PORT}/health", timeout=2)
        return r.status_code == 200
    except:
        return False

def clean_text(text):
    text = text.replace("®", "fi")
    text = text.replace("fi", "fi")
    text = text.replace("fl", "fl")
    text = re.sub(r"[^\x09\x0A\x0D\x20-\x7E]", "", text)
    return text.strip()

# =====================
# llama 起動 / 停止
# =====================
def start_llama():
    if is_llama_alive():
        print("✅ llama-server already running")
        return None

    print("🦙 Starting llama-server...")
    proc = subprocess.Popen(
        LLAMA_ARGS,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )

    start = time.time()
    while time.time() - start < STARTUP_TIMEOUT:
        if is_llama_alive():
            print("✅ llama-server ready")
            return proc
        time.sleep(2)

    print("❌ llama-server startup timeout")
    proc.terminate()
    sys.exit(1)

def stop_llama(proc):
    if not proc:
        return
    print("🛑 Stopping llama-server...")
    proc.terminate()
    try:
        proc.wait(timeout=10)
    except:
        proc.kill()

# =====================
# 翻訳
# =====================
def translate(text):
    text = clean_text(text)
    cached = cache.get(text)
    if cached:
        return cached

    text = text.strip()
    if not text:
        return ""

    # <|plamo:op|>dataset を削除し、直接指示から入る
    prompt = (
        "<|plamo:bos|><|plamo:op|>translation\n" # ← ここを直接 translation に
        "学術論文の翻訳タスクです。以下の英語を自然な日本語に訳してください。解説は一切不要です。\n"
        "<|plamo:op|>input lang=English\n"
        f"{text}\n"
        "<|plamo:op|>output lang=Japanese"
    )

    payload = {
        "prompt": prompt,
        "n_predict": N_PREDICT, # ここをグローバル変数に統一
        "temperature": 0.1,
        "cache_prompt": True,   # 高速化の鍵
        "stop": ["<|plamo:op|>", "<|plamo:bos|>", "<|plamo:eos|>"]
    }

    try:
        r = requests.post(LLAMA_URL, json=payload, timeout=REQUEST_TIMEOUT)
        r.raise_for_status()
        out = r.json().get("content", "").strip()

        # ===== バリデーション =====
        if not out:
            print(f"⚠️ Empty output for: {text[:20]}...")
            return text 

        # テンプレート漏れの除去
        out = re.sub(r"<\|.*?\|>", "", out).strip()

        # 短すぎる出力のチェック(ただし入力も短ければ許可)
        if len(out) < 3 and len(text) > 10:
             # 原文が長いのに訳が極端に短い場合はエラー扱いせず、原文を返す(安全策)
             print(f"⚠️ Output too short for: {text[:20]}...")
             return text

        cache.set(text, out)
        return out

    except Exception as e:
        print(f"⚠️ Translation failed: {e}")
        # 失敗時は原文を返す(「翻訳失敗」と書かれるより読みやすい)
        return text

# =====================
# 分割(文単位)
# =====================
def split_text_by_sentences(text, max_words=MAX_WORDS):
    # 改行をスペースに置換してから分割
    clean_text = text.replace('\n', ' ')
    # ピリオド等の後ろのスペースで分割(肯定先読み)
    sentences = re.split(r'(?<=[.!?]) +', clean_text)

    chunks = []
    buf = []
    current_count = 0

    for s in sentences:
        words_in_sent = len(s.split())
        if current_count + words_in_sent > max_words and buf:
            chunks.append(" ".join(buf))
            buf = [s]
            current_count = words_in_sent
        else:
            buf.append(s)
            current_count += words_in_sent

    if buf:
        chunks.append(" ".join(buf))
    return chunks

# =====================
# Markdown 翻訳
# =====================
def translate_md(in_md, out_md, with_en):
    with open(in_md, encoding="utf-8") as f:
        content = f.read()

    # 段落(空行区切り)ごとに処理
    blocks = [b.strip() for b in content.split('\n\n') if b.strip()]

    total = len(blocks)
    start_time = time.time()
    out_md.parent.mkdir(parents=True, exist_ok=True)

    with open(out_md, "w", encoding="utf-8") as out:
        for i, block in enumerate(blocks, 1):
            if block.startswith("#"):
                out.write(block + "\n\n")
                continue

            # 文単位で分割して翻訳
            parts = split_text_by_sentences(block)
            jp_parts = []
            for p in parts:
                jp_parts.append(translate(p))

            jp = " ".join(jp_parts)

            if with_en:
                out.write("**Original**\n\n")
                out.write(block + "\n\n")
                out.write("**Japanese**\n\n")
                out.write(jp + "\n\n---\n\n")
            else:
                out.write(jp + "\n\n")

            elapsed = time.time() - start_time
            eta = (elapsed / i) * (total - i)
            eta_s = time.strftime("%H:%M:%S", time.gmtime(eta))
            print(f"📑 {i}/{total} 完了 (ETA {eta_s})")

# =====================
# MinerU 関連 / main
# =====================
def run_mineru(pdf, outdir):
    print("📄 Running MinerU...")
    cmd = ["mineru", "-p", str(pdf), "-o", str(outdir)]
    subprocess.run(cmd)

def find_md_file(pdf_name, base_dir="tmp_mineru"):
    for p in [Path(base_dir) / pdf_name / "auto", Path(base_dir) / pdf_name / "hybrid_auto"]:
        if p.exists():
            md_files = list(p.glob("*.md"))
            if md_files: return md_files[0]
    return None

def copy_images(pdf_name, base_dir, output_md):
    search_path = Path(base_dir) / pdf_name
    images_src = next(search_path.rglob("images"), None)
    if images_src:
        images_dst = Path(output_md).parent / "images"
        if images_dst.exists(): shutil.rmtree(images_dst)
        shutil.copytree(images_src, images_dst)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("pdf")
    ap.add_argument("out_md")
    ap.add_argument("--with-en", action="store_true")
    ap.add_argument("--workdir", default="tmp_mineru")
    args = ap.parse_args()

    pdf = Path(args.pdf)
    md = find_md_file(pdf.stem, args.workdir)
    if not md:
        run_mineru(pdf, Path(args.workdir))
        md = find_md_file(pdf.stem, args.workdir)

    if not md:
        print("❌ No Markdown produced"); sys.exit(1)

    llama_proc = start_llama()
    try:
        translate_md(md, Path(args.out_md), args.with_en)
    finally:
        stop_llama(llama_proc)

    copy_images(pdf.stem, args.workdir, args.out_md)
    print("✅ Done:", args.out_md)

if __name__ == "__main__":
    main()

(6) 実行権限をつける。

$ chmod +x pdf2jp.py

(7) 以下のように実行する:

$ ./pdf2jp.py input.pdf output.md --with-en

これでPDFがMarkdownに変換されたあとそれを対訳モードで翻訳したMarkdownが生成されます。ouput.mdと同じディレクトリにimagesディレクトリがコピーされるので、ouput.mdpandocなりVSCodiumなりでPDFに変換すると英日対訳で図入りのきれいなPDFを作ることができます。

MinerUがとっても強力でした。はじめはYomiTokuを使ってたんですが、英語のPDFならMinerUがよいですね。AIパワーであっという間にPDFを解析してMarkdownとかに変換してしまいます。

今回はChatGPTとGeminiを使ったけど、せっかくだからllama.cppでgpt-oss-20bを使えばよかったと今更ながら後悔。まあ今回はこれでいいや。

試せる環境がある方はお試しあれ。