textbook機能の設計 - fjordllc/bootcamp GitHub Wiki

textbook機能の設計

前提: bootcamp の既存アーキテクチャ

項目 内容
フレームワーク Rails 8.1.1 / Ruby 3.4.3
DB PostgreSQL(pgvector 有効)
フロントエンド Hotwire(Turbo + Stimulus)/ Slim テンプレート ※React廃止予定
CSS Tailwind CSS 4.0
AI OpenAI (gpt-4) via ruby-openai / RubyLLM(embedding, ai chat generator)
ジョブ Solid Queue
既存の教材モデル PracticePage(Markdown本文)
既存の進捗モデル Learning(user × practice の状態管理)

データモデル

ER図(テキスト)

Textbook 1──* Chapter 1──* Section
                              │
                              │ body (Markdown)
                              │
User ──* ReadingProgress (user × section × block位置)
User ──* TextbookQuizAttempt (user × section × 回答)
Section ──* TermExplanation (単語キャッシュ)

テーブル設計

textbooks

教科書の単位。1つの教材 = 1レコード。

カラム 説明
id bigint PK
title string 教科書タイトル(例: 「Ruby入門」)
description text 概要
published boolean 公開フラグ(default: false)
practice_id bigint 紐づくプラクティス(nullable、連携用)
created_at datetime
updated_at datetime
  • practice_id で既存のプラクティスとリンク。プラクティスから「この教科書を読む」への導線になる。

textbook_chapters

章の単位。

カラム 説明
id bigint PK
textbook_id bigint FK → textbooks
title string 章タイトル(例: 「メソッドと引数」)
position integer 章の並び順
created_at datetime
updated_at datetime

textbook_sections

節の単位。教科書の最小コンテンツ単位。

カラム 説明
id bigint PK
textbook_chapter_id bigint FK → textbook_chapters
title string 節タイトル(例: 「メソッドとは何か」)
body text Markdown本文
estimated_minutes integer 推定読書時間(分)
goals text[] 学習目標(PostgreSQL array)
key_terms text[] 重要用語(PostgreSQL array)
position integer 節の並び順
created_at datetime
updated_at datetime

reading_progresses

読了位置の追跡。ユーザー × セクション × 位置。

カラム 説明
id bigint PK
user_id bigint FK → users
textbook_section_id bigint FK → textbook_sections
read_ratio float 読了割合(0.0〜1.0)
completed boolean セクション読了フラグ(default: false)
last_block_index integer 最後に読んだブロックのインデックス
last_read_at datetime 最終読書日時
created_at datetime
updated_at datetime
  • unique index: [user_id, textbook_section_id]
  • read_ratio はフロントから Intersection Observer で更新
  • completedread_ratio >= 0.9 等のしきい値で自動セット

term_explanations

単語説明のキャッシュ。

カラム 説明
id bigint PK
textbook_section_id bigint FK → textbook_sections
term string 単語
explanation text AI生成の説明文
created_at datetime
updated_at datetime
  • unique index: [textbook_section_id, term]
  • 同じ単語でもセクションごとに文脈が違うのでセクション単位でキャッシュ

textbook_quiz_attempts

ミニクイズの回答記録(Phase 2)。

カラム 説明
id bigint PK
user_id bigint FK → users
textbook_section_id bigint FK → textbook_sections
question text 出題内容
user_answer text ユーザーの回答
ai_feedback text AIの添削・フィードバック
correct boolean 正解判定
created_at datetime

Railsモデル構成

app/models/
  textbook.rb
  textbook/
    chapter.rb
    section.rb
  reading_progress.rb
  term_explanation.rb
  textbook_quiz_attempt.rb

モデルの関連

# app/models/textbook.rb
class Textbook < ApplicationRecord
  has_many :chapters, class_name: 'Textbook::Chapter', dependent: :destroy
  belongs_to :practice, optional: true
  scope :published, -> { where(published: true) }
end

# app/models/textbook/chapter.rb
class Textbook::Chapter < ApplicationRecord
  self.table_name = 'textbook_chapters'
  belongs_to :textbook
  has_many :sections, class_name: 'Textbook::Section', dependent: :destroy
  acts_as_list scope: :textbook
end

# app/models/textbook/section.rb
class Textbook::Section < ApplicationRecord
  self.table_name = 'textbook_sections'
  belongs_to :chapter, class_name: 'Textbook::Chapter', foreign_key: 'textbook_chapter_id'
  has_many :reading_progresses, dependent: :destroy
  has_many :term_explanations, dependent: :destroy
  acts_as_list scope: :textbook_chapter_id
end

# app/models/reading_progress.rb
class ReadingProgress < ApplicationRecord
  belongs_to :user
  belongs_to :section, class_name: 'Textbook::Section', foreign_key: 'textbook_section_id'

  def complete!
    update!(completed: true, read_ratio: 1.0, last_read_at: Time.current)
  end
end

# app/models/term_explanation.rb
class TermExplanation < ApplicationRecord
  belongs_to :section, class_name: 'Textbook::Section', foreign_key: 'textbook_section_id'
end

ルーティング

# config/routes/textbook.rb
resources :textbooks, only: %i[index show] do
  resources :chapters, only: %i[show], controller: 'textbooks/chapters' do
    resources :sections, only: %i[show], controller: 'textbooks/sections'
  end
end

namespace :api do
  namespace :textbooks do
    # 読了位置の更新(フロントからPATCH)
    resources :reading_progresses, only: %i[create update]
    # 単語説明の取得(フロントからGET、なければAI生成)
    resources :term_explanations, only: %i[show]
    # AIサイドカラムの質問(フロントからPOST)
    resources :questions, only: %i[create]
  end
end

URL例:

  • /textbooks — 教科書一覧
  • /textbooks/1 — 教科書トップ(章の一覧、進捗)
  • /textbooks/1/chapters/2/sections/3 — セクションを読む画面

コントローラ構成

app/controllers/
  textbooks_controller.rb
  textbooks/
    chapters_controller.rb
    sections_controller.rb
  api/
    textbooks/
      reading_progresses_controller.rb
      term_explanations_controller.rb
      questions_controller.rb

フロントエンド設計

セクション読書画面のレイアウト

┌──────────────────────────────────────┐
│  Chapter 3: メソッドと引数            │
│  Section 3.1: メソッドとは何か        │
├──────────────────────────────────────┤
│                                      │
│  [教科書の本文が全幅で表示]            │
│                                      │
│  メソッドとは、処理をまとめて          │
│  名前をつけたものです...              │
│                                      │
│  クリック可能な用語に下線              │
│  テキスト選択 →「ピヨルドに聞く」     │
│                                      │
│                    ┌──────────────┐  │
│                    │ いい調子!    │  │
│                    └───┬──────────┘  │
│                       🐥             │
├──────────────────────────────────────┤
│  ← 前のセクション  [3/12]  次へ →    │
└──────────────────────────────────────┘
  • 本文は全幅表示(サイドカラムなし)
  • ピヨルド(🐥)が右下に常駐。クリックでチャット、自動で吹き出し
  • モバイルでもそのまま動く

ピヨルド(伴走キャラクター)

すべてのAI機能・伴走機能はピヨルド経由で提供する。

場面 ピヨルドの振る舞い
普段(読んでるとき) 右下に静かに常駐
セクション完了時 吹き出しで褒める
久しぶりの訪問 「おかえり!前回の続きからいける?」
しばらく止まってる 「ここ難しいよね。やさしく説明する?」
単語クリック時 (ピヨルド経由ではなくtooltipで表示)
クリック時 チャットが開き、セクションについて質問できる
テキスト選択時 「ピヨルドに聞く」ボタン → 選択箇所について質問

技術構成

要素 技術
Markdown→HTML Redcarpet(既存利用)or CommonMarker
読了追跡 Stimulus controller + Intersection Observer → API PATCH
単語クリック Stimulus controller → API GET → tooltip表示
ピヨルド常駐・吹き出し Stimulus controller
ピヨルドAIチャット ruby_llm ai chat generator + Turbo Streams(Hotwire化後)
テキスト選択→質問 Stimulus controller + Turbo Streams(Hotwire化後)
進捗バー Stimulus

読了追跡の実装方針

  1. セクション本文をサーバー側でブロック(段落・コード等)に分割し、各ブロックに data-block-index を付与
  2. Stimulus controller が Intersection Observer で各ブロックの表示を監視
  3. ユーザーがスクロールすると last_block_indexread_ratio を計算
  4. デバウンスして API に PATCH(3秒ごと or スクロール停止時)
  5. セクション末尾まで到達 → completed: true に更新 → 褒めメッセージ表示

単語クリックの実装方針

  1. セクションの key_terms に登録された用語を本文中でマッチさせ、クリック可能なスパンに変換
  2. クリック → API GET /api/textbooks/term_explanations?section_id=X&term=Y
  3. キャッシュがあればそのまま返す。なければAI生成→保存→返す
  4. popover(Tippy.js等)で表示

ピヨルドAIチャットの実装方針(Hotwire化完了後)

  1. ruby_llm の ai chat generator をベースに実装
  2. ピヨルドをクリック → Turbo Frame でチャットUIを開く
  3. POST /api/textbooks/questions にセクションID + 質問文を送信
  4. サーバー側でセクション本文をコンテキストとしてLLMに渡す
  5. Turbo Streams でストリーミングレスポンスを返す

テキスト選択→質問の実装方針(Hotwire化完了後)

  1. Stimulus controller でテキスト選択を検知
  2. 選択テキストの近くに「ピヨルドに聞く」ボタンを表示
  3. クリックでピヨルドチャットが開き、選択テキストがコンテキストとして自動挿入
  4. 以降はピヨルドAIチャットと同じフロー

AI呼び出し設計

サービスオブジェクト構成

app/models/textbook/
  ai/
    term_explainer.rb      # 単語説明生成
    question_answerer.rb   # サイドカラムQ&A
    quiz_generator.rb      # ミニクイズ生成(Phase 2)
    draft_writer.rb        # 執筆支援(Phase 2)

単語説明(TermExplainer)

class Textbook::Ai::TermExplainer
  SYSTEM_PROMPT = <<~PROMPT
    あなたはプログラミング学習の教材アシスタントです。
    以下の教材の文脈で、指定された用語をわかりやすく説明してください。
    - 初学者にもわかるように、簡潔に(3〜5文程度)
    - この教材の文脈に沿った説明をすること
    - markdown形式で返すこと
  PROMPT

  def call(section:, term:)
    # キャッシュ確認
    cached = TermExplanation.find_by(textbook_section_id: section.id, term: term)
    return cached.explanation if cached

    # AI生成
    explanation = generate(section, term)

    # キャッシュ保存
    TermExplanation.create!(
      textbook_section_id: section.id,
      term: term,
      explanation: explanation
    )

    explanation
  end
end

ピヨルドQ&A(QuestionAnswerer)— Hotwire化完了後

class Textbook::Ai::QuestionAnswerer
  SYSTEM_PROMPT = <<~PROMPT
    あなたはプログラミング学習の教材アシスタントです。
    以下の教材セクションの内容に基づいて質問に答えてください。

    ルール:
    - このセクションの範囲内で答えること
    - 初学者にもわかるように説明すること
    - 関連する本文の箇所があれば引用すること
    - セクションの範囲外の質問には「このセクションでは扱っていません」と伝えること
  PROMPT

  def call(section:, question:)
    client = OpenAI::Client.new(access_token: ENV['OPENAI_ACCESS_TOKEN'])
    client.chat(
      parameters: {
        model: 'gpt-4',
        messages: [
          { role: 'system', content: SYSTEM_PROMPT },
          { role: 'user', content: "## 教材セクション\n#{section.body}\n\n## 質問\n#{question}" }
        ],
        stream: true  # ストリーミング
      }
    )
  end
end

伴走機能の設計

ルールベース(MVP)

褒め・うながしはまずルールベースで実装する。

class Textbook::Encouragement
  RULES = {
    section_completed: [
      'このセクション完了!いい調子です 🎉',
      'ここまで読めたのは大きい前進です 👏',
      'しっかり読めています。次も短いセクションです'
    ],
    chapter_completed: [
      'この章をクリアしました!すごい 🎊',
      '大きな区切りを超えました。少し休憩してもOKです'
    ],
    comeback_after_days: [
      'おかえりなさい!前回の続きから始められます',
      '久しぶりですね。まずは1セクションだけ読んでみましょう'
    ],
    idle_3_days: [
      '前回はここまで読めています。次のセクションは%{minutes}分くらいで読めます',
      '1セクションだけ、今日読んでみませんか?'
    ]
  }.freeze

  def for_event(event, context = {})
    messages = RULES[event]
    return nil unless messages
    format(messages.sample, **context)
  end
end

マイグレーション

class CreateTextbookTables < ActiveRecord::Migration[8.1]
  def change
    create_table :textbooks do |t|
      t.string :title, null: false
      t.text :description
      t.boolean :published, default: false, null: false
      t.references :practice, foreign_key: true, null: true
      t.timestamps
    end

    create_table :textbook_chapters do |t|
      t.references :textbook, null: false, foreign_key: true
      t.string :title, null: false
      t.integer :position, null: false
      t.timestamps
    end

    create_table :textbook_sections do |t|
      t.references :textbook_chapter, null: false, foreign_key: true
      t.string :title, null: false
      t.text :body, null: false
      t.integer :estimated_minutes
      t.text :goals, array: true, default: []
      t.text :key_terms, array: true, default: []
      t.integer :position, null: false
      t.timestamps
    end

    create_table :reading_progresses do |t|
      t.references :user, null: false, foreign_key: true
      t.references :textbook_section, null: false, foreign_key: true
      t.float :read_ratio, default: 0.0, null: false
      t.boolean :completed, default: false, null: false
      t.integer :last_block_index, default: 0
      t.datetime :last_read_at
      t.timestamps
      t.index [:user_id, :textbook_section_id], unique: true
    end

    create_table :term_explanations do |t|
      t.references :textbook_section, null: false, foreign_key: true
      t.string :term, null: false
      t.text :explanation, null: false
      t.timestamps
      t.index [:textbook_section_id, :term], unique: true
    end
  end
end

実装の優先順

Phase 1a(Hotwire化完了前に着手可能)

  1. マイグレーション + モデル — textbooks / chapters / sections / reading_progresses / term_explanations
  2. 管理画面(簡易) — 教科書・章・セクションのCRUD(mentor namespace)
  3. セクション読書画面 — Markdown全幅表示 + 読了追跡(Stimulus + Intersection Observer)
  4. 教科書トップ画面 — 章一覧 + 進捗表示
  5. ピヨルド常駐UI — 右下に表示 + 吹き出し(Stimulus)
  6. 伴走メッセージ — セクション完了時の褒め + 再開時のうながし(ルールベース)
  7. 単語クリック説明 — key_terms マッチ + AI生成 + キャッシュ(ストリーミング不要)

Phase 1b(Hotwire化完了後)

  1. ピヨルドAIチャット — ruby_llm ai chat generator + Turbo Streams
  2. テキスト選択→質問 — 選択箇所をコンテキストにしてピヨルドに質問

次にやること

  • マイグレーション作成
  • モデル + テスト
  • セクション読書画面のワイヤーフレーム
  • 管理画面
  • フロントエンド実装