0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIに勝手な参考書を勧めさせない — カリキュラム準拠でLLM出力を縛った受験学習アプリの設計

0
Last updated at Posted at 2026-07-06

はじめに / 何を作ったか

「志望校と今の状況を入れると、本番までの長期プランと"今日やること"まで出してくれる」受験戦略AI StudyPath AI を個人開発しています。

  • 技術スタック: Next.js 15 / React 19 / Firebase / OpenAI SDK / Capacitor(iOS・Android配布)
  • 想定読者: 個人開発でLLMプロダクトを作る人 / EdTechに関心がある人
  • デモ/URL: (公開URLは追記予定)

この記事は「作った自慢」ではなく、LLMの出力を"信頼できる形"に縛るための設計判断を主役にします。生成AIプロダクトで誰もがぶつかる「AIが適当なことを言う」問題への、具体的な対処です。

課題と背景

家庭教師をしていて、「今日何をやればいいか」で手が止まる"受け身"の生徒を多く見てきました。自走できる子向けのサービスは多いのに、その層を真正面から狙うものが少ない。そこを埋めたくて作りました。

ただ、受験の学習プランをLLMに任せると致命的な問題が出ます。AIが実在しない参考書や、塾の方針と噛み合わない本を平気で勧めるのです。受験は「何を使うか」の信頼性が命なので、ここを解けないとプロダクトになりません。

設計の主眼は次の3つに絞りました。

  1. LLM出力をカリキュラム(定番参考書リスト)に準拠させる
  2. 出力構造を JSON Schema で契約し、UIが壊れないようにする
  3. 完了履歴を渡して「同じ課題を再生成しない」を守らせる

以下、それぞれの実装を最小コードで示します。

設計判断1: LLM出力をカリキュラムに準拠させる

curriculum(塾の参考書DB準拠)と ai_auto(AIおまかせ)の2モードを持ち、profile.planMode で切り替えます。準拠モードでは、curriculum.json 由来の教材だけをプロンプトに入れ、「この教材・ステップのみ使え」と縛ります。

// api/generate/route.ts
const planMode: 'ai_auto' | 'curriculum' =
  profile?.planMode === 'ai_auto' ? 'ai_auto' : 'curriculum';

let curriculumBlock = '';
if (planMode === 'curriculum') {
  const curriculumContext = buildCurriculumPrompt(subjects);
  const exclusiveGroups = getExclusiveGroups(subjects);
  curriculumBlock = `## カリキュラムデータベース (この教材・ステップのみ使用してください)
${curriculumContext}

## 排他関係 (どちらか1冊のみ採用すること - 重要!)
${exclusiveGroups.length > 0 ? exclusiveGroups.join('\n') : '(なし)'}

⚠️ 全てのTodoには materialId/stepIndex を必ず指定してください。`;
}

ポイントは、プロンプトを組み立てる側で**「採用しない教材はそもそもプロンプトに入れない」**こと。同じ用途で複数の定番書(排他グループ)がある場合、AIに選ばせず、採用1冊だけを渡して他は除外します。「両方使ってしまう事故」をプロンプト設計で防ぐわけです。

// data/curriculum.ts
export function buildCurriculumPrompt(subjects: string[]): string {
  const lines: string[] = [];
  const exclusiveGroups = computeExclusiveGroups(subjects);
  const droppedIds = new Set<string>();
  for (const g of exclusiveGroups) {
    for (const m of g.materials) {
      if (m.id !== g.selected.id) droppedIds.add(m.id); // 採用されなかった側
    }
  }
  for (const sub of subjects) {
    for (const mat of getMaterialsForSubject(sub)) {
      if (droppedIds.has(mat.id)) continue; // ← プロンプトから除外
      lines.push(`- **[${mat.id}] ${mat.name}** (全${mat.totalSteps}ステップ)`);
    }
  }
  return lines.join('\n');
}

「AIに賢く選ばせる」より「AIに渡す選択肢を先に絞る」ほうが、信頼性は圧倒的に上がりました。

設計判断2: JSON Schema で出力を契約する

UIは analysis / phases / todos / encouragement が必ず揃っている前提で描画します。1つでも欠けると壊れるので、OpenAI の response_format: { type: 'json_schema', strict: true } にスキーマを渡し、必須フィールドをスキーマレベルで保証します。

// lib/constants.ts
export const PLAN_JSON_SCHEMA = {
  type: 'object' as const,
  properties: {
    analysis: { type: 'string' as const, description: '現状分析と志望校とのギャップ (3-5文)' },
    phases: { /* name, startDate, endDate, ... を required */ },
    todos: {
      type: 'array' as const,
      items: {
        type: 'object' as const,
        properties: {
          material:   { type: 'string' as const, description: 'カリキュラムの教材名' },
          materialId: { type: 'string' as const, description: '教材ID (例: 英語_01)' },
          stepIndex:  { type: 'number' as const, description: 'ステップ番号 (0始まり)' },
          // ... subject, task, estimatedMinutes, dueDate, phase, order
        },
        required: ['subject', 'task', 'material', 'materialId', 'stepIndex',
                   'estimatedMinutes', 'dueDate', 'phase', 'order'],
        additionalProperties: false,
      },
    },
    encouragement: { type: 'string' as const, description: '穏やかで前向きな励まし (2-3文)' },
  },
  required: ['analysis', 'phases', 'todos', 'encouragement', 'nextExamTarget', 'finalTarget'],
  additionalProperties: false,
};

additionalProperties: falserequired を全階層で効かせると、パースの try/catch とバリデーションコードがごっそり消えます。「AIの出力を信じてUIを書く」のではなく「契約を満たさない出力はそもそも返らせない」に倒すのが安定への近道でした。

設計判断3: 完了履歴を渡して"同じ課題を再生成しない"

学習は継続なので、毎回まっさらなプランを出されると困ります。直近200件の完了タスクを渡し、「続きから」生成させます。まずクライアントで履歴を組み立て:

// page.tsx
const completedHistory = todos
  .filter(t => t.status === 'done')
  .slice(-200) // 直近200件 (プロンプト肥大化を抑制)
  .map(t => ({
    subject: t.subject, material: t.material, materialId: t.materialId,
    unit: t.unit, stepIndex: t.stepIndex, task: t.task, completedAt: t.completedAt,
  }));

サーバー側で教材ごとに畳んで、絶対遵守ルールとしてプロンプトに展開します。

// api/generate/route.ts
return `## 🔴 既に完了済みのタスク・教材 (新プランに含めない、続きから生成すること)
${lines.join('\n')}

### 完了履歴の扱い (絶対遵守)
1. 同じ materialId + stepIndex の組合せは新プランに含めないこと
2. 完了済の教材は「続きの stepIndex」から再開する
3. ある教材を最後まで完了していたら、応用編 or 別教材の次フェーズへ進める`;

設計判断1で materialId / stepIndex を必須化しておいたおかげで、この「重複排除ルール」がキー比較で機械的に効きます。3つの判断が噛み合う形です。

結果 / 学び

  • 本番デプロイ済み。利用状況の数値は集計でき次第このセクションに追記します(現時点では実数のみ載せる方針のため未記載)。
  • 一番の学び: LLMの信頼性は「賢いプロンプト」より「渡す選択肢と出力契約を先に絞る設計」で作られる。カリキュラム準拠・JSON Schema・完了履歴の3つは、いずれも「AIの自由度を意図的に下げる」方向の設計でした。
  • もう一つ: 当初入れていたRPG風のゲーミフィケーション(XP・ストリーク演出)は、受け身層にはむしろプレッシャーになると判断して撤去し、"穏やかな継続装置"へ振り直しました。ターゲットによって「続く仕掛け」の形は変わります。

まとめ

  • 競合空白(受け身層)を狙い、カリキュラム準拠でLLM出力の信頼性を担保し、JSON Schema + 完了履歴で壊れず継続する形にした
  • 生成AIプロダクトの肝は「AIを賢くする」より「AIに渡す入力と、返させる出力の形を設計で縛る」こと

StudyPath AI: (公開URLは追記予定)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?