Claude Code × Medical Application

【Claude Code】Medicare Part D × Text-to-SQL Part3:プロンプト設計と hook

1. はじめに

Part2 では、文書がエージェントの仕事をどう決めるかを見ました。Part3 は指示と強制の回です。

文書に書いた約束は、エージェントが読めば守られます。ただし、守られたかどうかを確かめる仕事は人に残ります。この回では、その確認を人の手から外していく過程を、作った順番どおりに追います。

  1. 長い作業を任せる指示を書く
  2. アプリの中で Claude に渡すプロンプトを、エージェントに書かせる
  3. UI を作らせて、表示崩れを踏む
  4. 表示崩れの点検を skill に切り出す
  5. テストと型チェック、課金の確認を hook で自動で走らせる
Part3 の流れ。指示を書く → アプリ内プロンプト → UI と表示崩れ → 点検を skill に → 検査を hook に

2. 指示の設計

2-1. 1回の指示の型

Part1 では、1回の指示に入れる条件を4つ挙げました。止めどころ、人がやる操作、判断の分岐、完了の条件です。1つの作業を1回の指示で頼むなら、この4つで足ります。

2-2. 長い作業の指示に足すもの

いくつものフェーズにまたがる作業を任せるときは、指示を会話に打ち込むのではなく、指示書として文書にしました。最初の指示は「どれを、どの順で読むか」と「最初にどこで止まるか」だけにして、条件はすべて指示書の側に書いています。

指示書に書いた条件は次のとおりです。

条件 指示書に書いたこと
読む順序 CLAUDE.md と docs/DECISIONS.md を読み、次に指示書を最後まで読む
最初の停止点 読み終えたら、最初のフェーズの作業計画だけを出して止まる。実装は承認してから
フェーズごとの停止 各フェーズの最後に「作ったもの/動作確認の結果/詰まった点」を報告し、承認を待つ
外部への影響 課金が出るコマンドは、実行前に内容を示して確認を取る。IAM とサービスアカウントの作成は人がやる
仕様の確認 hook・skill・settings の書式は、記憶で書かず公式ドキュメントで確かめ、見た URL を DECISIONS に残す
迷ったとき 実装を進めず、選択肢を示して止まる
記録の義務 道具ごとに「文書だけのとき人がやっていたこと/道具があるとき」を記録する

1回の指示の4条件と比べて増えたのは、止まる場所が複数あることと、記録を残す義務です。長い作業では、途中で人が読み返せる記録がないと、承認の判断ができません。

「仕様は公式ドキュメントで確かめる」は、この連載のようにツールの仕様が頻繁に変わる題材では欠かせません。実際、MCP サーバーの起動フラグは、ドキュメントに書かれた名前がすでに旧名になっていました。確かめた URL は docs/DECISIONS.md に残っています。

2-3. 計画 → 承認 → 実行

指示書の運用は、どのフェーズも同じ3段です。

  1. エージェントが計画を出して止まる
  2. 人が計画を読み、承認するか直させる
  3. エージェントが実行し、報告を出して止まる

人が口を出すのは2の1か所だけです。都度の指示ではなく、止まる場所をあらかじめ決めておくのが、vibe coding との違いです。

2-4. 文書の約束と、強制する仕組み

ただし、指示書や CLAUDE.md に書いたことは、あくまで約束です。「課金が出るコマンドは確認を取る」と書いても、守られたかどうかは人が見て確かめるしかありません。

約束(文書) 強制する仕組み
課金が出るコマンドは確認を取る settings の ask(Part1)、課金ゲートの hook(6章)
ガードを省略しない ガードのテストを編集のたびに走らせる hook(6章)
画面を実際に見る 表示崩れの点検の skill(5章)

約束のうち、機械で確かめられるものは仕組みに移せます。3章以降は、その移し方の話です。

3. アプリ内プロンプト

アプリの中で Claude に渡すプロンプト(app/prompts/)も、エージェントに書かせました。作業フェーズの2にあたります。

ファイル 中身
system.md スキーマとルール。「順位に言及するのは並べ替えた指標だけ」など
glossary.yaml 言い方と列の対応、薬効クラスの辞書(Part2 の 3-4)
fewshot.yaml 質問と SQL の例

人が決めたのは、何を根拠に書くかです。用語はデータ辞書から、値は BigQuery の実データで確かめてから書く、と文書で指定しました。プロンプトそのものの文面は、エージェントの成果物です。

few-shot には、見分けてほしい例を対にして並べています。「折れ線にして」のように表示の形だけを変える質問と、集計が変わる質問です。前者では SQL を再実行せず、グラフの仕様だけを返します。ログの sql_rerun で、実際にそう動いたかを確かめられます。

この3つをまとめたシステムプロンプトは約1.2万トークンあり、毎回同じ内容を送ります。そこで cache_control を付け、2回目以降はキャッシュから読ませています。

4. 題材:UI 実装と表示崩れ

4-1. 「2,022」

UI を作らせて最初に踏んだのが、数値の表示崩れです。整数を一律に桁区切りしたため、年やコードまで数量として整形されました。

列 元の値 表示
year 2022 2,022
prscrbr_state_fips 01 1
prscrbr_zip5 90210 90,210

年・NPI・FIPS・ZIP は、数字でできていても数量ではなく識別子です。桁区切りも、先頭の0を落とすことも、してはいけません。

4-2. 判定を1か所に集める

直し方は、識別子かどうかの判定を web/lib/format.ts の isIdentifierColumn 1か所にまとめ、表・グラフの軸・ツールチップ・CSV 出力のすべての経路でそれを通すことです。表だけを直すと、グラフの軸や CSV で同じ崩れが残ります。

web/lib/format.ts。識別子の判定を1か所にまとめ、整形の最初に通す

4-3. CLAUDE.md への書き戻し

このとき、CLAUDE.md の「数値の扱い」に2つを書き戻しました。

  • 識別子は数値ではない。判定は1か所に集め、全経路に通す。踏んだ実例として「2,022」「90,210」を添える
  • 画面を実際に見る。数値の表示崩れは、型チェックもテストも素通りする

2つ目が、この回の後半につながります。「画面を見る」は大事な約束ですが、文書に書いただけでは、見たかどうかは分かりません。

5. 表示崩れの点検を skill に

5-1. display-check

「画面を実際に見る」を、手順として /display-check に切り出しました。SKILL.md は、冒頭で「判定は1か所に集約されている。点検はその1か所と、そこを通っていない経路が無いか、の2段で行う」と宣言し、5つの項目を順に確かめます。

項目 確かめること
A. 判定 isIdentifierColumn の正規表現に、点検する列名が実際に当たるか
B. 全経路 表・グラフの軸・ツールチップ・地図・CSV のすべてが、その判定を通っているか
C. 境界の型変換 BigQuery → Python → JSON → JavaScript の各段で、型が変わって壊れていないか
D. 欠測と 0 NULL が「—」で出るか。0 に化けていないか
E. 実画面 アプリを起動し、質問を流して、表とグラフを目で見る(省略しない)

直すときは判定の1か所かその呼び出し側を直し、コンポーネントに個別の例外を足さない、とも書いています。

display-check の SKILL.md。判定は1か所、D は欠測と 0、E は実画面(省略しない)

5-2. 点検が見つけたもの

skill を1回動かすと、アプリの不具合が2つ見つかりました。

見つかったこと 見つけた項目 直し方
系列付きのグラフで、抑制された値(NULL)が 0 の棒として描かれていた D(コード) NULL は点を欠けさせる
オピオイド処方率が「927.4%」と表示されていた E(実画面) 元データで既に % の列は 100 倍しない(PERCENT_ALREADY)

2つ目は、率の列を一律に100倍して % 表示していたのが原因です。CMS の opioid_prscrbr_rate は、元データの時点で既に % の値でした。コードを読むだけでは「率の列を % にしている」ことしか分からず、画面で数字を見て初めておかしいと気づけます。

オピオイド処方率の列。左:修正前は「927.4%」。右:元データで既に % の列を除外した修正後

直し方も、4章と同じく判定を1か所に足す形です。元データで既に % の列を PERCENT_ALREADY に列挙し、100 倍する処理より先に分岐させました。

PERCENT_ALREADY。既に % の列は、率の整形(100 倍)より先に分岐する

5-3. 人が決めたこと

エージェントは不具合を見つけても、アプリの挙動は自分で直さず、報告して止まりました。点検の skill の役目は見つけることで、アプリをどう直すかは人が決めます。2つとも、人が判断して直しています。

実画面の確認は、最初は失敗しました。数日前に起動したままの古い API サーバーが残っていて、質問がエラーで落ちたのです。人が立ち上げたプロセスなので、エージェントは止めずに報告しました。SKILL.md には、起動のコマンドと「古いプロセスが残っていないか疑う」を書き足しています。Part2 と同じく、skill は1回動かしてから信用します。

6. 検査を自動で走らせる hook

6-1. 文書だけのとき

hook が無いとき、テストや型チェックは、人が「テストを回して」「tsc を通して」と毎回言って走らせていました。課金コマンドの前に --plan を見せる、というのも、人が覚えている運用でした。

hook は、Claude Code がツールを使う前後に、決めておいたスクリプトを走らせる仕組みです。.claude/settings.json の hooks に書きます。

hook の2つのタイミング。PreToolUse は実行前に止め、PostToolUse は実行後に結果を返す

6-2. 3本の hook

hook タイミング 動き
ガードの自動テスト 編集の後(PostToolUse) app/guards.py・app/prompts/*・app/tools.py・tests/* を編集すると pytest tests/ が走る。落ちたら結果の末尾がエージェントに返る
lint と型チェック 編集の後(PostToolUse) .py を編集すると ruff check、web/ の .ts・.tsx を編集すると tsc --noEmit が走る
課金ゲート 実行の前(PreToolUse) ./data/load.sh --run は、30分以内に同じ対象の --plan を実行していなければ止まる
.claude/settings.json の hooks。実行前(PreToolUse)に課金ゲート、編集の後(PostToolUse の Edit|Write|MultiEdit)にテストと lint

止め方は2通りです。実行の前に止めるときは、hook が「拒否」の判定を返します。編集の後に問題を伝えるときは、終了コード 2 で終わり、エラー出力をエージェントに読ませます。

課金ゲートは、Part1 で置いた settings の ask より前に働きます。--plan を見せていない --run は、人に確認のダイアログが出る前に止まります。

–plan なしの –run を、課金ゲートの hook が止めた。エージェントは回避せず、止まったことを報告する

6-3. テストが先に要る

ガードの hook を置くには、走らせるテストが要ります。それまでガードのテストは無く、動作確認は1問を通すスクリプトだけでした。そこで tests/test_guards.py を先に作りました。BigQuery には接続しない、50ケースのテストです。

書いた時点で、1本が落ちました。 WITH a AS (...), b AS (...) のように CTE を2つ以上並べると、2つ目以降を CTE と認識できず、「データセット名が付いていない: b」と正しい SQL を拒否していたのです。原因は正規表現の \b(?:WITH|,) で、\b がカンマの手前で単語の境界を取れませんでした。(?:\bWITH|,) に直しています。

評価の30問では、CTE を2つ以上使う SQL が1本も出なかったため、この不具合は見つかっていませんでした。評価で通ることと、ガードが正しいことは別です。

6-4. わざと壊して確かめる

hook が働くかは、わざと引っかかる操作をして確かめました。

操作 hook の反応
guards.py の SELECT * を検出する正規表現を壊して保存 pytest が走り、test_select_star_rejected の5件が「拒否されなかった」と返ってきた。元に戻すと何も言わずに通る
.py に不要な import を足す ruff が止めた
.ts に型の合わない代入を入れる tsc が止めた
--plan なしで ./data/load.sh --run geo_drug 30分以内の --plan の記録が無いので止まり、確認のダイアログは出なかった
ガードを壊す編集をさせたところ。hook が pytest を走らせ、5件の「拒否されなかった」を返して編集を差し戻した

6-5. 詰まった点

  • hook はコマンドの全文を見る。 課金ゲートのテストを1行のコマンドで書いたら、その文字列の中に ./data/load.sh --run が含まれていたため、hook 自身に止められました。安全側の反応として受け入れ、テストはファイルに書いて実行しています
  • 型チェックは毎回走る。 tsc は web/ 全体を検査するので、1回10秒ほどかかります。.tsx を続けて編集すると毎回待つことになりますが、今は許容しています
  • hook は、決めたツールにしか掛からない。 編集後の検査は、Claude Code の編集ツール(Edit・Write)に掛けてあります。ガードを壊す操作を頼んだとき、エージェントが Bash のコマンドでファイルを書き換えたことがあり、そのときは hook が走りませんでした(エージェントは自分でテストを流して報告しました)。課金ゲートも load.sh だけを見ているので、bq load を直接打つ経路は、Part1 の settings の ask が受け持ちます。どの経路に掛かっていないかを把握しておくのも、設計のうちです
  • 失敗した --plan も記録される。 実行の後に --plan を記録していますが、その --plan が成功したかは確実には取れません。記録に期限を付け、対象が一致するときだけ通すことで、実害を抑えています

7. 今回のハーネス

道具 このリポジトリでの実体 文書だけのとき人がやっていたこと
指示書 フェーズごとの停止点、外部への影響、仕様の確認、記録の義務 会話のたびに条件を言い直す
skill display-check 「画面を実際に見る」を覚えていて、自分の目で確かめる
hook ガードの自動テスト、ruff・tsc、課金ゲート 「テストを回して」「tsc を通して」と毎回言う。--plan を見せたか覚えておく
テスト tests/test_guards.py(50ケース) 1問を通して動いたことを確かめる
権限と停止条件 Part1 の settings(--run は ask) 課金が出るコマンドを目で確かめる

8. まとめ

  • 長い作業は、指示を文書にする。 読む順序、フェーズごとの止まる場所、外部への影響、仕様の確認、記録の義務を書いておけば、人が口を出すのは承認の1か所で済む
  • 約束は、確かめられる形にして仕組みに移す。 「画面を見る」は点検の skill に、「テストを回す」「課金前に --plan を見せる」は hook に
  • 仕組みは、わざと壊して確かめる。 テストは書いた時点で本物の不具合を見つけ、点検の skill は画面の「927.4%」を見つけた

任せられる範囲は、事前にどれだけ検証可能にしておいたかで決まります。 hook は、その検証を人が覚えていなくても走らせる仕組みです。

次のステップ

Part4 では、評価と運用を扱います。正解のある30問でエージェントが作ったアプリを測り、落ちた質問を用語辞書やガードに書き戻して再評価する改善ループと、その記録の残し方を見ていきます。

出典

  • Anthropic, Claude Code Docs(hooks / skills / settings)
  • Anthropic, Prompt caching (Claude API Docs)
  • CMS, Medicare Part D Prescribers Data Dictionary (data.cms.gov)

コードは GitHub で公開しています: github.com/HerzLeben/medicare-partd-text-to-sql