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を使えばよかったと今更ながら後悔。まあ今回はこれでいいや。

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

2026年2月19日木曜日

大阪出張のはなし

今年度も1月末に集中講義で大阪に行ってきた。

集中講義はなかなか体力を使うので、これまで授業後に人に会うような機会はあまりなかったのだけれど、ふと思うところがあって、大阪の先輩や友達と会ってきた。なつかしい限り。久々にお会いして、久々なのに当たり前のように受け入れてくださる方々のありがたさにふれたような気がする。

で、本当に久々にお会いした友達と話をして、お別れするときのはなし。

泣きそうになっている自分に気づいた。というか泣いてた。

びっくりした。自分にそういう気持ちが残っていたんだなあって。

僕にはもう大阪に家がなく、帰るところがない。唯一帰るところだったかすたネットも、もうなくなってしまった。だからもう大阪は、未練がないというか、自分がこれまで住んできた地域の1つに過ぎないと思っていた。

だけど、びっくりした。自分にこんな気持ちがあっただなんて、知らなかった。本当に知らなかったんだ。

しばし呆然となった。

僕はいまちょうど、もう一歩大人になろうとしている自分と向き合っているような気がしている。大人になる過程のなかで、きっと大切な経験をしているのだと思う。

前へ進もう。大人になろう。そんな思いをかみしめながら、福岡に戻った。

2026年1月4日日曜日

2026年を迎えて

あけましておめでとうございます。今年もよろしくお願いします。

例年の大みそかには1年のまとめ記事を,正月には1年の抱負記事を書いてたんですが,昨年さぼってしまってからすっかりめんどくさくなってしまいました。

これまでほどの内容はもう書くつもりはないけど,去年の活動を振り返ってみます。

(1) 日本PCA協会の会長をやってた

といっても会長らしいこと全然してないですし,あまり公言もしてないのですが。

まだまだよちよち歩きの協会で,やるべきことを整理して,コミュニケーションができるところまではもっていけたかなあと思っています。想定外の他業務のためリソースが割けず,志半ばではあります。まあこればかりは仕方ない。

昨年12月末をもって会長職は退きましたが,引き続きPCAを盛り上げていきたいですねえ。

協会の内側のことなのであまり詳しいこと書けないのですが,仕組みをつくるにあたってオープンソースソフトウェア(OSS)に助けてもらいました。知っててよかったOSS。

(2) 「くらりネット」の立ち上げ準備

かすたネット」や「ほたるネット」に続く「くらりネット」の立ち上げ準備に着手しました。

福岡のことをまだまだ知らないですし,あちこちウロウロしてみるのはやはり大事なことですねえ。いろいろなことがわからない。でもそれがおもしろいですね。たくさんの学生さんにお手伝いいただき,たくさんの方にお会いしました。ありがたいですね。

といってもこれも想定外の他業務のためなかなか前に進められませんでしたが……。

(3) 学会大会の海外招聘講演に登壇した

海外招聘講演に登壇しました|Takashi Oshie

去年いちばん大変だったのはこれでしょうかね……。

Rodgers先生とちょっとでも英語でコミュニケーションがとれたらと思い,Duolingoをがんばってみたんですが,Duolingoぐらいではどうにもならないことがよくわかりました。まあでも,いい勉強になりました。

(4) 原稿書いた

ハイブリッドPCAGIPの論文を書きました。あとはあんな本やこんな本の原稿をいくつか書いたくらいかなー。本じゃないけど実はいまも書いてる。近々ご報告できるかと思います。

あと積み論文が4本ぐらいあるのですよ。書きたいけど全然時間がなかった。今年なんとかしたいところですねえ。

(5) Nintendo Switch 2を買った

マイニンテンドーストアの抽選に何度も落ち,その他店舗の抽選にも落ち続け,リアル店舗でも売っておらず大変苦労しましたが,楽天ブックスで昨年8月に無事入手しました。

任天堂の据え置き型ゲーム機って,発売日にリアル店舗に並んで買うというのをいつもやってきたんですが,今回はどこにも全然売ってなかったですねえ。これも時代か。その昔,山口で合宿企画をやったとき,Wii Uを行列に並んでから参加したのはいまとなってはいい思い出。

忙しくてあんまりゲームできなかったんですが,『星のカービィ ディスカバリー Nintendo Switch 2 Edition + スターリーワールド』と『カービィのエアライダー』は相当やりました。

『スターリーワールド』はコロシアムの一番最後で詰んでしまったのですよ。あんな強いボスどうやって倒すの……。あと『カービィのエアライダー』はやばいゲームですねえ。他のゲームをやっててもシティトライアルやりたくなるぐらいにはハマっちゃいました。時間泥棒だ。ただ,クリアチェッカーはなかなか埋まらん。

2026年はどうなるの

さてさて,2026年はどんな年になるのやら。去年はやりたいことがなかなかやれず心残りが多かったので,今年はなんとかしたいところ。ただ,今年もなんだか忙しくなりそうなんですよね。まあそういうお年頃か。

今年はケルンの学会大会に参加予定です。うまくいけば発表もしたい。英語がんばらないとなあと思いつつ,Duolingoはアンインストールしちゃいました(だめやん)。

あと生成AIを使った研究をなんとかがんばっていきたいですね。先越されちゃったけど,僕も頑張ろう。

プライベートでは,先日参加した文学フリマがおもしろかったので,今年は出店する側になりたいなあと目論んでいます。ただ,本をつくる時間あるのか……?

限られたリソースのなかで,やりたいことをどうやって実現していくかが今年の頑張りどころになりそうですねー。今年中には全部収まらなさそうだ。

というわけで,今年もどうぞよろしくお願いいたします。

2025年12月21日日曜日

最近のたかしさん。その3

Zoomを1日あたり8時間程度3日連続使う機会があったのですが,体がしんどすぎてスライムみたいになってしまいました。そのまま休みなく仕事だったので,この1週間は人間のふりをするのが大変でした。持病(?)の湿疹が悪化したり腹痛に襲われたりと,なかなかあかん感じでしたが,たくさん寝て,だいぶ回復しました。まだちょっとだるいけど,よかったよかった。

それにしても,本当に苦手だなあ,Zoom。慣れる気がしない。

近況報告ですが,いくやさんの記事を読んで,6年前組んだPCのグラボをPalit GeForce RTX 5060 Ti Infinity 3 16GBに換装しました。これで生成AIというかローカルLLMを使った研究がはかどる,はず。ひとまずLM Studioでgpt-oss-20bがさくっと動きました。時間ができたらllama.cppをビルドしてみよう。

これ,73,800円で買ったんですが,いま見ると79,800円になってますね。最近まで71,800円だった気がする。値上がり本当におそるべし。

ついでにCPUもRyzen 7 5700Xあたりにすればよかったかなあと思いつつ,めんどくさいのでRyzen 7 2700Xのままです。いやまあ,安いし替えたほうがいいんだろうけど,そんなにCPUで困ってないような,ほしいような。

ところで今年の研究室訪問は去年みたいな全員ではなく,ゼミのQ&A動画をOBS Studioで撮影して希望者からメールが届いたらGoogleドライブで共有(Geminiに書かせたApps Scriptで完全自動),動画を視聴してもなお質問のある学生さんには事前申込のうえ座談会を実施,という作戦にしました。

去年は延べ60人来て地獄を見たんですが,今年はこの作戦でずいぶん負担が減りました。あれはあれで楽しかったんですけどね。

写真は某本学のクリスマスリース。今年も暮れていきますねえ。

2025年10月4日土曜日

「これは、大学教員のコスしてるだけだから」

わたなれの漫画快活CLUBでだらだらと読んだ。そのなかで、主人公のれな子に容姿を褒められた香穂ちゃんのセリフに目を奪われた。

「これは、陽キャのコスしてるだけだから……」

陽キャのコスしてるだけ。なんだそれ。なんだかめっちゃいいな。

僕は、アイデンティティみたいなものをあまり持ちたくないという欲求をずっと持っている。何してるかよくわからない人として見られていたい。ずっとふらふらして生きていたい。

そう思って、ふと自分について「これは、大学教員のコスしてるだけだから」とつぶやいてみた。いいな。なかなかしっくりくる。

もちろんコスをする以上は、ちゃんと役割を果たす必要がある。コスとはいえ仕事なんだし、もちろんやることはやりますよ。自分が専門家であることを認めようとしないことが、罪であるということを、いまの自分はよく知っている。

でも、これはコスなんですよ。中身はいつまでもふらふらしている、何してるかよくわからないおっさんなんです。

2025年9月16日火曜日

全国ツアー状態

  • 8/17(日)-23(土) 集中講義 + α @ 山口
  • 8/31(日) 日本人間性心理学会第44回大会 @名古屋(海外招聘企画シンポジスト)
  • 9/5(金)-7(日) 日本心理臨床学会第44回大会 @神戸(6(土)15:30-17:30口頭発表司会)
  • 9/12(金)-9/17(水) 公認心理師実習演習担当教員養成講習会 + α @東京
という全国ツアー状態でしたが、明日でようやく落ち着きます。いやー忙しかった……。まあ渋谷のドラえもんストアに行けたのでよしとしましょう。

シンポジウムとかの話は改めてnoteあたりに書きますかね。

2025年3月13日木曜日

最近のたかしさん。その2

正月はぼーっと過ごしてその後急に忙しくなって,3月になって時間ができました。最近やってることをつらつらと書いてみます。

最近やってること(その1) 友達がいないのでAIと遊ぶ

OllamaのフロントエンドAlpacaで遊んでます。いくやさんの記事を読んで以来,ずっとやりたいやりたいと思ってましたが,ようやく試せました。バージョン5.1.0からFlatpakでインストールするとき

$ flatpak install flathub com.jeffser.Alpaca

だけじゃなくて

$ flatpak install com.jeffser.Alpaca.Plugins.Ollama

必要になっているっぽいのでその点だけ要注意。

こんなふうにすらすらコードを書かれたら人類いらないんじゃないかという気がしてくるのがつらい。いや,ちゃんと動かないこともけっこう多いのでまだ大丈夫……? まあでも時間の問題ですよねえきっと。

ローカルLLMはある研究の関係で触ってるんですが,なかなかうまくいかなかったり。このへんをちゃんとやろうと思ったらつよつよCPUとGPUが必要そうです。PCを新調するときがきたか……?

最近やってること(その2) サ活

最近よくお風呂とサウナに行っています。いまのところ那珂川清滝が最強。ちょっとお高いけど,サウナにテレビがないのがいいんですよねえ。

山口にいた頃はサウナの良さに気づいていなかった……,きっと湯田温泉にもいいサウナがあっただろうに……。もったいないことをした……。

しかしまあサウナを楽しめる日が来るとは思わなかった。これがおっさんになるということか……。

最近やってること(その3) 漫喫を満喫

快活CLUBに行ってだらだらと漫画を読むのが好き。最近は「カードキャプターさくらクリアカード編」と「推しの子」を読んでます。

しかし快活CLUBでだらだら過ごすというのはおっさんの過ごし方なのか……? 無駄に若いような気もする……,いやそうでもないのか?