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 |
| 既存の教材モデル | Practice → Page(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 で更新completedはread_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 |
読了追跡の実装方針
- セクション本文をサーバー側でブロック(段落・コード等)に分割し、各ブロックに
data-block-indexを付与 - Stimulus controller が Intersection Observer で各ブロックの表示を監視
- ユーザーがスクロールすると
last_block_indexとread_ratioを計算 - デバウンスして API に PATCH(3秒ごと or スクロール停止時)
- セクション末尾まで到達 →
completed: trueに更新 → 褒めメッセージ表示
単語クリックの実装方針
- セクションの
key_termsに登録された用語を本文中でマッチさせ、クリック可能なスパンに変換 - クリック → API GET
/api/textbooks/term_explanations?section_id=X&term=Y - キャッシュがあればそのまま返す。なければAI生成→保存→返す
- popover(Tippy.js等)で表示
ピヨルドAIチャットの実装方針(Hotwire化完了後)
- ruby_llm の ai chat generator をベースに実装
- ピヨルドをクリック → Turbo Frame でチャットUIを開く
- POST
/api/textbooks/questionsにセクションID + 質問文を送信 - サーバー側でセクション本文をコンテキストとしてLLMに渡す
- Turbo Streams でストリーミングレスポンスを返す
テキスト選択→質問の実装方針(Hotwire化完了後)
- Stimulus controller でテキスト選択を検知
- 選択テキストの近くに「ピヨルドに聞く」ボタンを表示
- クリックでピヨルドチャットが開き、選択テキストがコンテキストとして自動挿入
- 以降はピヨルド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化完了前に着手可能)
- マイグレーション + モデル — textbooks / chapters / sections / reading_progresses / term_explanations
- 管理画面(簡易) — 教科書・章・セクションのCRUD(mentor namespace)
- セクション読書画面 — Markdown全幅表示 + 読了追跡(Stimulus + Intersection Observer)
- 教科書トップ画面 — 章一覧 + 進捗表示
- ピヨルド常駐UI — 右下に表示 + 吹き出し(Stimulus)
- 伴走メッセージ — セクション完了時の褒め + 再開時のうながし(ルールベース)
- 単語クリック説明 — key_terms マッチ + AI生成 + キャッシュ(ストリーミング不要)
Phase 1b(Hotwire化完了後)
- ピヨルドAIチャット — ruby_llm ai chat generator + Turbo Streams
- テキスト選択→質問 — 選択箇所をコンテキストにしてピヨルドに質問
次にやること
- マイグレーション作成
- モデル + テスト
- セクション読書画面のワイヤーフレーム
- 管理画面
- フロントエンド実装