自由テキストを構造化 JSON に変換する AI 解析機能について、定量的なテストハーネスを自作し、12 ラウンドの改善ループを回して 93 ケース全 Pass (100%) を達成するまでの記録です。
途中で判明した「プロンプトに足すほど精度が下がる」という逆説、AI の transient 失敗との向き合い方、700 回の AI 呼び出しでも 費用 $0.16 に収まったキャッシュ設計など、同種のシステムを開発する方に役立つ知見をまとめました。
対象システムの概要
勤務シフト最適化 SaaS を開発しています。スタッフは勤務希望を、管理者は編成ルールを、それぞれ 自由テキスト で入力します。これらをシフト最適化エンジンへ渡すために、AI が構造化 JSON へ変換する必要があります。
フォームで項目を一つひとつ入力させると UX が悪くなるため自然言語入力を採用しました。その代償として「AI が正しく解析できているか」の品質保証が必要になります。
変換パスは 2 つあります。
1. 勤務希望の解析 (analyze-request)
スタッフが自由テキストで希望を入力し、AI が [{type, date, priority}] の配列に変換します。
入力:
「6月15日は絶対休みたいです。毎週水曜は遅番希望。夜勤は少なめに。」
出力:
[
{ "type": "off", "date": "2026-06-15", "priority": 95 },
{ "type": "遅番", "date": "recurring-wednesday", "priority": 70 },
{ "type": "preference", "note": "夜勤少なめ", "priority": 50 }
]
type には off (休み)、work (出勤)、パターン名 (早番/遅番 等)、pattern_avoid (特定パターン回避)、preference (日付不定の一般希望) が入ります。
2. 編成ルールの解析 (analyze-rules)
管理者が自然言語でルールを書き、AI が 8 種のルールテンプレート DSL に変換します。
入力:
「新人は夜勤を月4回まで。夜勤の翌日は必ず休みにする。ベテランを毎週末に最低1名入れる。」
出力:
[
{ "rule": "period_pattern_count", "params": { "attribute": "新人", "pattern": "夜勤", "max": 4 } },
{ "rule": "pattern_transition_required", "params": { "from": "夜勤", "to": "off" } },
{ "rule": "staff_subset_coverage", "params": { "attribute": "ベテラン", "scope": "weekend", "min": 1 } }
]
8 種のテンプレートは「連続勤務の上限」「特定属性のカバレッジ」「ペアリング強制」「属性別優先度」などシフト編成でよく現れるルールパターンに対応しています。
注: 実際のプロンプト全文はここに書くと叱られるので出せなくて申し訳ないです。設計の考え方と改善の記録を以下に共有します。
テストハーネスの設計
ディレクトリ構成
本番コードのパーサクラスをそのまま呼び出す E2E テストとして設計しました。モックを挟まず、実際の AI 呼び出しを含みます。
scripts/ai_eval/
├── run_wish_tests.php # 勤務希望テストランナー
├── run_localrule_tests.php # 編成ルールテストランナー
├── inspect_failures.php # 失敗ケースを読みやすく出力
├── show_parsed.php # 解析結果の可視化
├── fixtures/
│ ├── wish_cases.json # 希望解析テストケース (40 件)
│ ├── localrule_cases.json # 編成ルールテストケース (42 件)
│ └── localrule_holdout.json # ホールドアウト (12 件、過学習検証用)
├── prompts/ # プロンプト改善の試作版を置く場所
└── results/ # 実行結果 JSON (gitignore)
採点ロジックの設計
テストケースは期待値を以下の粒度で記述できます。
勤務希望 (WishCaseScorer):
| キー | 意味 |
|---|---|
must_have |
出力に必ず含まれていなければ FAIL |
must_have_recurring |
recurring 形式 or 期間内の該当日がすべて展開済みで含まれているか |
must_have_any |
候補リストのうち最低 1 つに一致すれば OK |
should_have |
あれば加点、欠落は warning (FAIL にしない) |
must_not_have |
含まれていれば FAIL |
expect_count_range |
出力件数の許容範囲 [min, max] |
編成ルール (LocalRuleCaseScorer):
| キー | 意味 |
|---|---|
must_rules |
rule + params_match を満たすルールが含まれること |
must_rules_any |
候補のいずれか 1 つに一致すれば OK |
must_rule_types_subset |
指定の rule template ID がすべて parsed に含まれること |
min_rules / max_rules
|
出力ルール数の許容範囲 |
expect_status_in |
parseStatus の許容値リスト (省略時 [ok, partial]) |
must_have_any (および must_rules_any) を用意したのは重要な設計判断です。AI は自然言語を複数の合理的な解釈で変換できます。たとえば「新人とベテランを夜勤に組む」は、「新人+ベテランのペアリング必須」と解釈しても、「新人カバレッジ + ベテランカバレッジ」という 2 ルールに分けて解釈しても、どちらも正解です。採点を厳格にしすぎると、正しい解釈を誤検知で落としてしまいます。
ホールドアウトは最初から用意する
改善に使うケース (訓練セット) とは別に、最初から手をつけないケース (ホールドアウト) を 12 件用意しました。
ホールドアウトの条件
- 訓練セットと似た語彙・構文を使わない
- adversarial ケースを含む (意地悪なケース)
例: 「数年前から守ってる方針で、夜勤は3連続まで」
→ 「数年」を数値として取り込まないか?
例: 「週7回以上できる人を採用したい」
→ 採用文脈のノイズを無視できるか?
後から作ると、無意識に訓練セットに似たものを作ってしまいます。 ホールドアウトは設計の最初に用意するのが正解でした。
改善ループの実際
R1: まずベースラインを測る (リトライなし)
DeepSeek V4 Flash、希望解析 39 ケース → Pass: 34 (87.2%)
失敗の内訳:
- 3 件: thinking mode で content が空になった (transient)
- 1 件: 同義語の照合失敗
- 1 件: 相対日付の解釈失敗
この段階ではプロンプトを触らず、まず「何が起きているか」を把握することに集中しました。
R2: リトライを入れるだけで解決した
リトライ x3 を追加 → Pass: 39/39 (100%)
最初の重要な学び: 失敗の大半は AI 側の transient な問題でした。プロンプトを直す前に、まずリトライで再現性を確認することが必要です。「失敗 = プロンプト不備」と思い込んで修正すると、関係のない場所を触ることになります。
R3 〜 R7: 編成ルール解析の改善
編成ルール解析も同様にベースライン測定 → リトライ追加で 30 ケース中 29 Pass。
唯一の失敗は「ベテランと組ませて」を 2 ルールに分けず 1 ルールに統合するという AI の解釈差 でした。これは採点側で must_have_any を使い「どちらの解釈でも正解」にすることで解決しました。プロンプトは触っていません。
R8: テストケースを拡充して弱点を発見
テストケースを 30 → 42 件に増やしたとき、初めて見えた弱点が 2 つあります。
- 数値表現の揺れ: 「数名」「数人」を AI が 1 と解釈する傾向がある
-
「ゼロ」の厳密性: 「夜勤はゼロ」を AI が
discouraged(緩い制約) で代替する傾向がある
どちらもプロンプトに明示的なガイドを足すことで改善できました。
R9 〜 R10: 過剰なガイドを足してしまった
数値の解釈ガイドを追加するとき、調子に乗って「多めに → min 3」「ほぼ毎日 → min 5〜6」「たまに → max 2〜3」のような 具体的な数値マッピング まで書いてしまいました。
訓練ケースでは問題ありませんでしたが、これが後で問題になります。
R11: ホールドアウトで過学習を検証 → 問題発見
ホールドアウト 11/12 = 91.7%
失敗ケース:「育児中スタッフの夜勤はゼロ」
→ 期待: max_count=0 (厳密な 0)
→ 実際: discouraged (軟弱な制約)
調べると、R9〜R10 で足したガイドのうち「多めに → min 3」が別のケースで誤誘導を起こしていることも判明。そして「ほぼ毎日 → 5〜6」は AI に 完全無視 されていました。最初からノイズだったわけです。
R12: ガイドを「本質的な原則のみ」に絞る → 全件 Pass
投機的な数値マッピングを全て削除し、言語として本質的な原則だけを残しました。
【数値表現の解釈】
- 漢数字 (一/二/三/...) → アラビア数字に変換
- 「数X」(数名/数人/数回) → 複数を意味する。ふつう 2〜3
- 「複数」「何名か」「数名」 → min は 2 以上 (1 にしない)
- 「半分」「○分の○」「○割」 → 絶対数換算不能なら出力に含めない
「数年前」「数十名から」のような修飾語の「数」は数値ルールの値ではない。
編成ルール (訓練) 42/42 = 100%
ホールドアウト 12/12 = 100% ← 「ゼロ」も正しく max_count=0 に
最終ベンチマーク
DeepSeek V4 Flash + retry x3、改善版プロンプトで:
| セット | ケース数 | PASS | レート |
|---|---|---|---|
| 希望解析 (訓練) | 39 | 39 | 100.0% |
| 編成ルール解析 (訓練) | 42 | 42 | 100.0% |
| 編成ルール解析 (ホールドアウト) | 12 | 12 | 100.0% |
| 合計 | 93 | 93 | 100.0% |
実行時間 (3 セット合計): 約 17 分 / 累積 AI 呼び出し: 約 700 回
コストとキャッシュ効率
「ループで 700 回も AI を呼ぶとトークンが爆発するのでは?」という懸念は自然です。実際には、これだけのテストを回しても費用はたったの $0.16 でした。キャッシュのおかげです。
DeepSeek はもともと安いですし。ちなみに、個人情報にかかわるものは一切投げていません。
| 項目 | トークン数 | 割合 |
|---|---|---|
| Input (Cache hit) | 1,918,464 | 96% |
| Input (Cache miss) | 79,402 | 4% |
| Output | 331,668 | — |
| 合計 | 2,329,534 | — |
Input トークンの 96% がキャッシュヒットしています。
キャッシュが効く条件
DeepSeek (Anthropic Claude も同様) のプレフィックスキャッシュは、リクエストの先頭部分が前回と完全一致する場合に、その部分の計算をスキップ する仕組みです。効果的に使うための条件は 3 つです。
- 固定部分を先頭に置く — system メッセージや user メッセージの冒頭が変わらないこと
- 固定部分が十分長い — 短いと効果が薄い (DeepSeek は 64 トークン以上で有効)
- 動的部分は末尾に置く — 途中に動的要素が入ると、それ以降はキャッシュが切れる
このプロジェクトのプロンプトは最初からこの構造を意識して設計しています。
[system]
ロール定義 (不変)
テンプレート仕様 (不変) ← ここまでが毎回キャッシュされる
ルール説明 (不変)
[user]
Examples (動的: 部署ごとのパターン名で差し替え)
実際の入力テキスト (動的: リクエストごとに変わる)
system 側の固定部分が数百〜数千トークンと長く、700 回の呼び出しで毎回同じ先頭を持つため、96% のキャッシュヒット率を達成できています。DeepSeek はキャッシュヒット時のコストがミスの約 1/10 と安く、ループテストの「コスト爆発」懸念は実質的に解消します。
得られた知見
1. transient 失敗をプロンプト不備と混同しない
DeepSeek V4 Flash は長く曖昧な入力で thinking mode に入ることがあり、そのとき content が空になります。これはプロンプトの問題ではなく AI 側の一時的な問題です。
見分け方: 同じ入力を 3〜5 回リトライして再現するか確認する。リトライで解消するなら transient、毎回同じ間違いをするならプロンプトの問題。
DeepSeek V4 Flash の transient 失敗パターン
- thinking mode で content が空
- JSON 解析不能な形式で応答
- 4 連続 thinking mode (retries=3 でも吸収できないことがある)
対策
- retries=3 を基本とし、JSON 解析失敗も retry 対象にする
- フォールバックチェーン (DeepSeek → Anthropic など) で別プロバイダーに自動切替
2. 投機的にガイドを足すと逆効果になる
これが最も重要な教訓です。
「多めに → min 3」のような固定マッピングをガイドに足すと:
- AI がそのガイドを 無視する ことがある (= 最初からノイズだった)
- 別のケースで AI を 誤誘導する ことがある (= 精度悪化)
プロンプトに追記すべき内容は「テストで実証された本質的な原則」のみ。 特定の数値や表現を固定マッピングで指定するのは避ける。
3. 採点が厳格すぎると正解を落とす
AI は複数の合理的な解釈をしてきます。採点ロジックには「どちらでも正解」を表現できる must_have_any を用意し、テストを通すために期待値を単一の解釈に固定しないことが大切です。
4. ホールドアウトは最初から用意する
後から作ると、訓練セットに似た語彙・構文のケースを無意識に選んでしまいます。意地悪なケース (adversarial) もここに含めておくと、プロンプトの過学習を早期に発見できます。
5. Examples のパターン名は動的に差し替える
Few-shot Examples でパターン名をハードコードすると、AI がそのコードをそのまま真似て出力します。
悪い例: "type": "early" ← 英語コードをハードコード → AI がそのまま出力
良い例: "type": "早番" ← 実際のラベルで例示 → AI が正しいラベルを学ぶ
部署ごとにパターン名が異なる場合は、実行時に動的に Examples を組み立てる ことで、AI が常に正しいラベルを参照できます。
6. system/user 分割でキャッシュ効率を最大化する
前述のキャッシュ設計 (固定部分を system 側、動的部分を user 側) は、キャッシュ効率だけでなくプレースホルダー置換のためでもあります。
Examples を user 側に置かないと、部署ごとのパターン名を差し込むプレースホルダー置換が機能しません。キャッシュ効率と動的 Examples の両方を実現するために、この分割は不可欠でした。
改善ループのチェックリスト
[ ] ベースライン測定 (リトライなし) でまず現状を把握する
[ ] 失敗を「transient」か「構造的」かに分類する (リトライで確認)
[ ] リトライ機構を追加してから、プロンプト改善を始める
[ ] ホールドアウトを訓練セットとは別に最初から用意する
[ ] 採点ロジックに must_have_any を用意し、複数解釈を許容する
[ ] プロンプト追記は「テストで実証された原則」に限定する
[ ] 改善後は訓練セット + ホールドアウトの両方で回帰確認する
[ ] ホールドアウトで精度が下がったら、追記を削る (過学習)
AI モデルを差し替えるときの回帰テスト手順
テストハーネスを用意しておくと、モデル差し替えのコストが大幅に下がります。
# 別プロバイダーで全ケース実行
php run_wish_tests.php --provider=anthropic --retries=3
php run_localrule_tests.php --provider=anthropic --retries=3
php run_localrule_tests.php --fixture=fixtures/localrule_holdout.json --provider=anthropic --retries=3
# 失敗ケースの分析
php inspect_failures.php results/latest.json
訓練 100% / ホールドアウト 100% を維持していれば差し替え OK。差異が出たら inspect_failures.php で原因分析 → プロンプト調整 → 再テストのループです。
まとめ
体系的な改善ループで最も価値があったのは「ベースライン測定」と「ホールドアウト」の 2 つです。ベースラインなしに改善を始めると何が効いたか分からず、ホールドアウトなしにプロンプトを改善すると過学習しているかどうか分かりません。
プロンプトへの追記は「少ないほどいい」という原則も実証できました。AI は人間より賢く曖昧な表現を汎化推論できます。具体的なマッピングを教えようとするとかえって迷走します。原則を示して判断は AI に委ねる、というスタンスが結果的に精度を上げました。
そして 700 回の AI 呼び出しで $0.16 という費用は、プロンプトを最初からキャッシュを意識した構造にしたおかげです。ループテストを回す前から設計しておくことが重要で、後から分割しようとすると大幅な改修が必要になります。
この技術を使った製品 — ShiftMaster LM
本稿で紹介した AI 解析システムは、勤務シフト最適化 SaaS ShiftMaster LM の中核コンポーネントとして稼働しています。
ShiftMaster LM (LM = Language Model を用いた Labor Management) は、スタッフの勤務希望や管理者の編成ルールを自然言語で入力できる点を最大の特長とするシフト管理サービスです。月次のシフト表作成において、AI による希望解析・ルール解析・自動シフト割当て・公平性スコアリングまでを一貫して提供します。
本記事のテストハーネスは、この「自然言語 → 構造化データ」変換の品質を保証する基盤として、プロンプト改善のたびに回帰テストを実行し、日々の機能改善を支えています。
- ShiftMaster LM: https://shiftmaster.emuyn.net
動作環境: DeepSeek V4 Flash、PHP 8.x、さくらレンタルサーバー (API は外部から呼び出し)



