はじめに
前回の記事で、Everything Claude Code (ECC) 環境で初めて本格的な開発を始めた 10 日間のことを書きました。git も知らなかった初心者が、PDCAサイクルを何度も回しながら開発を覚えていく話でした。
今回は、その 10 日間の後半で作った pdf2anki というツールの話をします。
アプリではなくコンテンツが問題だった
G検定の勉強のために、忘却曲線に基づく出題アプリを作りました。Python/Streamlit で Web 版、Swift/SwiftUI で iOS 版。ECC 環境の TDD で実装し、ADR で意思決定を記録しながら、2 週間で動くものができました。
できてから気づきました。自分が作っていたのは Anki の再発明だったのです。
20 年以上の歴史を持つ Anki と同じものを 2 週間で作り直す意味はありません。しかし開発の過程で、本当のボトルネックが見えました。アプリではなくコンテンツです。
iOS 版では PDF からテキスト化したデータを正規表現と LLM で 410 問の問答ペアに抽出するアルゴリズムを実装しました。しかし、表記揺れや章構造の不一致で精度が安定しませんでした。「第 1 章」と「第1章」(スペースの有無)でマッチングが壊れるような世界です。
「どんなテキストからでも高品質な Anki カードを自動生成する」ツールがあれば、アプリ本体は Anki に任せられます。
そうして作り始めたのが pdf2anki です。
$ pdf2anki convert textbook.pdf -o cards.tsv
PDF、テキスト、Markdown を入力すると、Claude API でフラッシュカード(TSV/JSON)を出力する CLI ツールです。
この記事では、pdf2anki を作る過程で踏んだ 6 つの落とし穴 と、その防御策を書きます。Claude API をこれから使う人が同じ穴を踏まずに済むように。
前回の記事で書いた「しつこく質問する」姿勢は、今回も変わりません。Claude Code に「なぜこうするのか?」「他の方法はないのか?」と聞き続けました。その質問と回答の中から見えてきた設計判断を、できるだけ正直に書きます。
1. LLM の出力 JSON は壊れる前提で設計する
起きたこと
Claude API にカード生成を依頼すると、返ってくる JSON がそのままパースできないことがあります。
プロンプトに「JSON だけを返せ」と書いても、マークダウンの ```json ``` で囲んでくることがあります。配列の途中に説明テキストが混じることもあります。カンマが余分だったり、閉じ括弧が足りなかったりします。
LLM の出力は確率的です。「こう返せ」と指示しても、100% の保証はありません。
失敗した対応
最初は json.loads() で一括パースし、失敗したら全体をリトライしていました。10 枚のカードのうち 1 枚だけフォーマットが壊れていても、10 枚全部を再生成します。API コストが無駄に膨らみました。
防御策: カード単位でバリデーションし、部分的な成功を許容する
for i, item in enumerate(data):
try:
card = AnkiCard.model_validate(item)
cards.append(card)
except (ValidationError, TypeError) as e:
logger.warning("Skipping invalid card at index %d: %s", i, e)
10 枚中 1 枚が壊れていても、残り 9 枚は救います。
「全か無か」ではなく「部分的な成功を許容する」設計。 これは LLM を組み込むツール全般に通用する原則だと思います。
JSON のラッピング(```json ```)は正規表現で事前に剥がしてからパースに回します。泥臭い前処理ですが、こういう小さな防御の積み重ねが LLM 連携の安定性を支えています。
持ち帰り: LLM の出力パースは「壊れる前提」で書く。全体リトライではなく、個別スキップ+ログで回復力を持たせる。
2. LLM が生成したカードの品質はバラバラ
起きたこと
JSON が正しくパースできても、中身の品質は保証されません。
実際に出てきた問題カードの例です:
- 表面が「説明してください」だけ。何を説明するのかわからない
- 裏面が 500 文字超。カードではなくレポート
- 1 枚に 3 つの概念が詰め込まれている。Anki のカードは 1 枚 1 概念が原則
- リスト問題なのに QA 形式で出力されている。穴埋めにすべき
「LLM に任せたから大丈夫」は危険な前提でした。
失敗した対応
全カードを LLM に再評価させる方式を最初に試しました。生成にも評価にも API を叩くので、コストが単純に倍になります。100 枚のカードを作るたびに 200 回の API コール。現実的ではありません。
防御策: 3 層パイプラインで品質を担保する
第 1 層(コード判定・LLM 不要): ヒューリスティックスコアリング
6 つの軸で 0〜1 のスコアを付け、重み付き合計が 0.90 以上なら合格。LLM は呼びません。
| 軸 | 重み | チェック内容 |
|---|---|---|
| 表面の質 | 25% | 10〜200 文字、疑問符の有無 |
| 裏面の質 | 25% | 5〜200 文字、簡潔さ |
| カード種別の適合 | 15% | リスト内容なのに QA になっていないか |
| Bloom レベル | 10% | 認知レベルが内容に合っているか |
| タグの質 | 10% | 階層タグが付いているか |
| 原子性 | 15% | 1 枚 1 概念になっているか |
原子性の判定が面白かったです。裏面を句点で分割し、文数が多ければ「複数概念が混在している」と判定します。
# 裏面が 3 文以上 → 複数概念の疑い
sentences = [s for s in _SENTENCE_SPLIT_RE.split(back) if s.strip()]
if len(sentences) >= 3:
score = max(0.3, 1.0 - len(sentences) * 0.15)
# 「また、」「さらに」→ 追加情報の接続詞 → さらに減点
if _MULTI_CONCEPT_RE.search(back):
score = max(0.2, score - 0.15)
「また、」「さらに」が出てきたら、1 枚のカードに情報を詰め込みすぎている可能性が高いです。人間が見ても「これは分割したほうがいいな」と思うカードを、コードで検出できます。
第 2 層(LLM 使用): 0.90 未満のカードだけを Claude に批評させる
批評結果は 3 択: improve(書き直し)、split(分割)、remove(削除)。最大 2 ラウンドです。
ポイントは 全カードに LLM を使わない こと。スコア 0.90 以上のカードは第 1 層で素通りさせます。実測では生成カードの 60〜70% が第 1 層を通過しました。LLM 批評が必要なのは 30〜40% です。
第 3 層(コード判定・LLM 不要): 重複検出
文字バイグラムの Jaccard 類似度で重複を検出します。表面の類似度が 0.7 を超えたら重複フラグを立てます。
前回の記事で書いた ECC の TDD と同じ発想です。テストを先に書くように、品質の基準を先に定義する。基準がなければ「良くなった気がする」で終わります。
持ち帰り: 「LLM で判定できること」と「コードで判定できること」を分ける。コードで済む判定に LLM を使うのは、精度ではなくコストの問題。
3. API コストは「見えない」と怖い
起きたこと
Claude API は従量制です。Sonnet で入力 $3/M tokens、出力 $15/M tokens。
100 ページの PDF を処理したらいくらかかるか。実行してみるまでわかりません。
初期テスト中、うっかり長い PDF を処理して $2 近く使ったことがあります。学習用の個人ツールでこの出費は痛いです。
防御策は 3 つ
① 実行前のコスト見積もり
preview コマンドで、API を叩かずに見積もりを出します。
$ pdf2anki preview textbook.pdf
Estimated cost: $0.42 (Sonnet) / $0.11 (Haiku)
Sections: 12 | Chunks: 8 | Tokens: ~45,000
ユーザーが「これなら OK」と判断してから convert を実行する流れにしました。
② budget_limit で暴走防止
CostTracker に予算上限(デフォルト $1.00)を設定し、API コールのたびに累積コストをチェックします。超えたらそこで止まります。
@dataclass(frozen=True, slots=True)
class CostTracker:
budget_limit: float = 1.00
records: tuple[CostRecord, ...] = ()
frozen=True で不変にしています。前回の記事で触れた「不変性の原則」がここで効きました。API コールのたびに新しい CostTracker インスタンスを返します。途中で値が書き換わる心配がなく、「いまいくら使ったか」を常に正確に追跡できます。
③ テキスト量に応じたモデル自動選択
短いテキスト(10,000 文字未満、30 枚未満)ならコストが約 1/4 の Haiku を使い、多ければ Sonnet にルーティングします。
_SONNET_TEXT_THRESHOLD = 10_000 # 文字数
_SONNET_CARD_THRESHOLD = 30 # カード枚数
単純な閾値分岐ですが、短いテキストの処理コストが大幅に下がります。
持ち帰り: 従量制 API を使うツールには「実行前の見積もり」「実行中の上限」「モデルの使い分け」の 3 点セットを入れる。ユーザーの財布を守るのは開発者の責任。
4. 長文 PDF の分割を間違えると、カードの文脈が壊れる
起きたこと
Claude の入力トークン上限は約 200K ですが、一度に大量のテキストを投げるとカードの品質が落ちます。分割が必要です。
失敗した対応
最初はテキストを文字数で均等に切っていました。当然、章の途中で切れます。
「第 3 章の後半」と「第 4 章の前半」が混ざったチャンクから生成されたカードは、文脈がおかしくなりました。ある章の用語定義が別の章の概念と混同されます。
防御策: セクション分割 + breadcrumb で文脈を保持する
Markdown の見出し(#、##、###)で論理的に分割する方式に変えました。
各セクションに breadcrumb(パンくずリスト) を付与します。Web サイトの「ホーム > 製品 > iPhone」のような、いま全体のどの階層にいるかを示すナビゲーションです。
breadcrumb: "本論 > 第1章 論書名の意味 > 1.1 語源"
Claude はこの breadcrumb を見て「いま第 1 章の語源の話をしている」と理解できます。チャンクの先頭にこのコンテキストがあるだけで、生成されるカードの文脈が格段に正確になりました。
見出し階層は heading_stack という辞書で追跡します。H2 が出現したら、それ以下の H3 情報をクリアします。
heading_stack: dict[int, str] = {}
# H2 出現 → H3 をクリア
keys_to_remove = [k for k in heading_stack if k >= level]
1 セクションが 30,000 文字を超えたら段落単位でサブ分割し、見出しに (cont.) を付けて連続セクションだと示します。
日本語テキスト特有の罠
2 つあります。
1 つ目。Markdown 見出しがないテキストへの対応です。日本語の書籍なら「第一章」「(1)」「一、」といったパターンで見出しを検出するフォールバックを入れました。
2 つ目。トークン推定の誤差です。CHARS_PER_TOKEN = 4 という定数は英語基準です。日本語は 1 トークンあたり 2〜3 文字なので、コスト推定が実際の半分程度になります。これは既知の未修正問題です。日本語を扱うなら係数を 2.5 程度に修正する必要があります。
持ち帰り: 長文を LLM に渡すときは「意味の境界」で切る。機械的な文字数分割は文脈を壊す。分割後のチャンクに「いまどこの話か」のメタ情報を付与すると、出力品質が大きく改善する。
5. 画像対応はコストとの闘い
起きたこと
PDF にはテキストだけでなく図やチャートもあります。
Claude API にはテキストだけでなく 画像も入力できる機能(Vision) があります。写真や図を送ると、Claude が「この画像に何が写っているか」を理解して回答してくれます。これを使えば、教科書の図表からもカードを生成できます。
「全ページを画像として Vision に投げれば完璧では?」と思いました。
コストを計算して目が覚めました。
画像のトークンコストは (幅 × 高さ) ÷ 750 です。100 ページを全ページ画像化すると画像だけで約 15 万トークン。テキストと合わせて 30 万トークン、約 $1。テキストだけなら $0.15 程度です。7 倍のコスト差です。
防御策: 3 つの閾値で画像処理を制御する
① 画像カバレッジ閾値: 20%
pymupdf でページ内の画像面積を計算し、ページ面積の 20% 以上を画像が占めるページだけ Vision に回します。テキスト主体のページはテキスト抽出で十分です。
② DPI: 150 に固定
Vision の制約は長辺 1568px、最大 1.15 メガピクセルです。
- 300 DPI: きれいだがトークンが倍。1 画像 3,000 トークン超
- 72 DPI: 安いが図中の文字が潰れる
- 150 DPI: 文字が読める最低限の解像度。1 画像 1,500 トークン程度
③ 1 ページ最大 5 画像
5 枚 × 1,500 トークン = 7,500 トークン。budget_limit と組み合わせれば暴走しません。
この 3 つの妥協で、Vision ありの処理コストをテキストのみの 1.5〜2 倍に抑えました。7 倍にはなりません。
持ち帰り: Vision は強力だが高い。「全部画像で投げれば楽」という誘惑に負けず、テキスト抽出で済む部分はテキストで処理する。画像は本当に必要なページだけに絞る。
6. プロンプト変更の効果が測れないと、改善が博打になる
起きたこと
プロンプトを書き換えたとき、カードの品質が上がったのか下がったのかわかりません。「良くなった気がする」では心許ないです。LLM の出力は非決定的で、同じプロンプトでも毎回違う結果が出ます。
防御策: 期待カードとのキーワードマッチで品質を自動テストする
LLM 開発では、出力品質の自動評価を Eval(エバル) と呼びます。
pdf2anki では「このテキストからはこういうカードが生成されるべき」という期待値を YAML で定義し、実際の出力と照合する方式を採りました。
- id: "dl-001"
text: |
過学習(オーバーフィッティング)とは、訓練データに対して
過度に適合し、未知のデータに対する汎化性能が低下する現象である。
対策としてドロップアウト、早期終了、データ拡張などがある。
expected_cards:
- front_keywords: ["過学習", "何"]
back_keywords: ["訓練データ", "汎化性能", "低下"]
card_type: qa
- front_keywords: ["過学習", "対策"]
back_keywords: ["ドロップアウト", "早期終了", "データ拡張"]
card_type: qa
生成されたカードにキーワードが含まれているかで一致度を計算し、Recall(網羅率)・Precision(正確率)・F1(両者のバランス指標)を出力します。
$ pdf2anki eval --dataset evals/dataset.yaml
Recall: 0.78 | Precision: 0.85 | F1: 0.81
キーワードマッチは完璧ではありません。「ニューラルネットワーク」と「神経回路網」を同一視できません。
しかし重要なのは「プロンプト変更で回帰(=前より悪くなること)が起きていないか」の検知です。完璧な意味理解ではありません。プロンプトを変えるたびに pdf2anki eval を回して F1 が下がっていないか確認する。これだけで改善が博打ではなくなります。
ECC の TDD で学んだことと同じです。テストがあれば、リファクタリングしても安心できます。Eval があれば、プロンプトを書き換えても安心できます。
持ち帰り: LLM の出力品質は「テストケース」で管理する。完璧な自動評価でなくていい。「回帰を検知できる」だけで、プロンプト改善の生産性が大きく変わる。
おわりに
pdf2anki を作る過程は、妥協の連続でした。
| やりたかったこと | 現実 | 妥協点 |
|---|---|---|
| LLM に完璧な JSON を返させたい | 出力は壊れる | 個別スキップで部分的成功を許容 |
| 全カードを LLM で品質評価したい | コストが倍になる | ヒューリスティックで 60〜70% をフィルタ |
| コストを気にせず使いたい | 従量制は怖い | 見積もり + 上限 + モデル選択 |
| テキストを均等に分割したい | 文脈が壊れる | セクション分割 + breadcrumb |
| 全ページ画像で投げたい | 7 倍のコスト | カバレッジ閾値 + DPI + 枚数制限 |
| プロンプト改善を感覚で判断 | 回帰に気づけない | キーワードマッチ Eval |
どれも「理想の設計」ではなく「現実との折り合い」の産物です。
開発全体を通して効いた判断基準が一つあります。「これをやらない場合、同じ時間で何ができるか」 を常に問うことです。
途中で「OpenAI にも対応すべきでは」と考えました。$10 クレジットが残っていたからです。実装を見積もると 1,000 行、10 時間。$10 回収のために時給 $1 で働く計算になります。同じ 10 時間で画像カード生成、TUI、Eval フレームワークが作れました。
前回の記事で書いた ECC の PDCA サイクルが、ここでも回っています。
- Plan: Claude Code に「なぜこうするのか?」をしつこく質問し、設計の背景を理解する
- Do: TDD でテストを先に書き、実装する
- Check: Eval でプロンプトの品質を測定し、ヒューリスティックでカードの品質を検証する
- Act: ADR で「なぜ OpenAI 対応を捨てたか」「なぜ画像閾値を 20% にしたか」を記録する
Claude API は強力なツールです。しかし「API を叩けば解決する」と思った瞬間に、コストが膨らみ、品質が不安定になり、デバッグが困難になります。API の力を引き出すのは、その周辺に積み上げる防御的な設計です。
この記事が、同じ道を歩く人の参考になれば幸いです。
pdf2anki は GitHub で公開 しています。