From 51fd92e10ec36b5bc934fb3d480a2261f6c6e955 Mon Sep 17 00:00:00 2001 From: Masaki Komagata Date: Sun, 29 Mar 2026 11:32:24 +0900 Subject: [PATCH] Add Piyo Chat feature for textbook sections ## Overview Enables AI-powered chat with Piyo mascot while reading textbook sections. Users can ask questions about the current section and get contextual responses. ## Implementation ### Database - piyo_chat_messages table: stores chat history per user/section - PiyoChatMessage model with validations ### Backend - PiyoChatService: contextual AI responses using RubyLLM + section info - API::Textbooks::PiyoChatMessagesController: chat history + message creation ### Frontend - piyo_chat_controller.js: chat panel UI with message rendering - Enhanced piyo companion: click to toggle chat panel - CSS for chat panel and message bubbles ### Integration - Extends existing piyo companion partial with section-specific chat - Maintains conversation history per section - Error handling and loading states ## Files Added - db/migrate/20260329000000_create_piyo_chat_messages.rb - app/models/piyo_chat_message.rb - app/services/piyo_chat_service.rb - app/controllers/api/textbooks/piyo_chat_messages_controller.rb - app/javascript/controllers/piyo_chat_controller.js - test/models/piyo_chat_message_test.rb - test/services/piyo_chat_service_test.rb - test/fixtures/piyo_chat_messages.yml ## Files Modified - config/routes/textbook.rb: added piyo_chat_messages routes - app/models/user.rb: has_many piyo_chat_messages - app/views/shared/_piyo_companion.html.slim: chat panel UI - app/views/textbooks/sections/show.html.slim: pass section_id --- app/assets/stylesheets/common-imports.css | 1 + .../shared/blocks/_piyo-companion.css | 174 ++++++++++++++++++ .../piyo_chat_messages_controller.rb | 52 ++++++ .../controllers/piyo_chat_controller.js | 129 +++++++++++++ app/models/piyo_chat_message.rb | 11 ++ app/models/textbook/section.rb | 1 + app/models/user.rb | 2 + app/services/piyo_chat_service.rb | 61 ++++++ app/views/shared/_piyo_companion.html.slim | 142 +++++++++++++- app/views/textbooks/sections/show.html.slim | 2 +- config/routes/textbook.rb | 2 + ...0260329000000_create_piyo_chat_messages.rb | 17 ++ db/schema.rb | 16 +- test/fixtures/piyo_chat_messages.yml | 11 ++ test/models/piyo_chat_message_test.rb | 32 ++++ test/services/piyo_chat_service_test.rb | 30 +++ 16 files changed, 678 insertions(+), 5 deletions(-) create mode 100644 app/assets/stylesheets/shared/blocks/_piyo-companion.css create mode 100644 app/controllers/api/textbooks/piyo_chat_messages_controller.rb create mode 100644 app/javascript/controllers/piyo_chat_controller.js create mode 100644 app/models/piyo_chat_message.rb create mode 100644 app/services/piyo_chat_service.rb create mode 100644 db/migrate/20260329000000_create_piyo_chat_messages.rb create mode 100644 test/fixtures/piyo_chat_messages.yml create mode 100644 test/models/piyo_chat_message_test.rb create mode 100644 test/services/piyo_chat_service_test.rb diff --git a/app/assets/stylesheets/common-imports.css b/app/assets/stylesheets/common-imports.css index cb7bca44dc8..d0927afdf50 100644 --- a/app/assets/stylesheets/common-imports.css +++ b/app/assets/stylesheets/common-imports.css @@ -140,3 +140,4 @@ @import "./shared/blocks/form/_form-textarea.css"; @import "./shared/blocks/form/_form-table.css"; @import "./shared/blocks/_o-empty-message.css"; +@import "./shared/blocks/_piyo-companion.css"; diff --git a/app/assets/stylesheets/shared/blocks/_piyo-companion.css b/app/assets/stylesheets/shared/blocks/_piyo-companion.css new file mode 100644 index 00000000000..b29cde6c99c --- /dev/null +++ b/app/assets/stylesheets/shared/blocks/_piyo-companion.css @@ -0,0 +1,174 @@ +.piyo-companion { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 1000; +} + +.piyo-companion__button { + background: none; + border: none; + cursor: pointer; + padding: 0; + position: relative; +} + +.piyo-companion__image { + width: 64px; + height: 64px; +} + +.piyo-companion__bubble { + position: absolute; + bottom: 72px; + right: 0; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 12px; + padding: 0.75rem 1rem; + max-width: 240px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.piyo-companion__bubble::after { + content: ''; + position: absolute; + bottom: -8px; + right: 24px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #fff; +} + +.piyo-companion__message { + margin: 0; + font-size: 0.875rem; + line-height: 1.4; +} + +.piyo-chat-panel { + position: absolute; + bottom: 72px; + right: 0; + width: 320px; + max-height: 400px; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 12px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.piyo-chat-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid #e0e0e0; + font-weight: bold; + font-size: 0.875rem; +} + +.piyo-chat-panel__close { + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + color: #666; + padding: 0; + line-height: 1; +} + +.piyo-chat-panel__close:hover { + color: #333; +} + +.piyo-chat-panel__messages { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 200px; +} + +.piyo-chat-panel__msg { + max-width: 85%; + padding: 0.5rem 0.75rem; + border-radius: 12px; + font-size: 0.8125rem; + line-height: 1.5; + word-break: break-word; +} + +.piyo-chat-panel__msg--user { + align-self: flex-end; + background: #dbeafe; + color: #1e3a5f; +} + +.piyo-chat-panel__msg--assistant { + align-self: flex-start; + background: #f3f4f6; + color: #333; +} + +.piyo-chat-panel__input { + display: flex; + gap: 0.5rem; + padding: 0.75rem; + border-top: 1px solid #e0e0e0; +} + +.piyo-chat-panel__textarea { + flex: 1; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 0.5rem; + font-size: 0.8125rem; + resize: none; + min-height: 2.5rem; + max-height: 5rem; + font-family: inherit; +} + +.piyo-chat-panel__textarea:focus { + outline: none; + border-color: #60a5fa; +} + +.piyo-chat-panel__send { + background: #3b82f6; + color: #fff; + border: none; + border-radius: 8px; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + cursor: pointer; + white-space: nowrap; +} + +.piyo-chat-panel__send:hover { + background: #2563eb; +} + +.piyo-chat-panel__send:disabled { + background: #93c5fd; + cursor: not-allowed; +} + +.piyo-companion__badge { + position: absolute; + top: -4px; + right: -4px; + width: 12px; + height: 12px; + background: #ef4444; + border-radius: 50%; + border: 2px solid #fff; +} diff --git a/app/controllers/api/textbooks/piyo_chat_messages_controller.rb b/app/controllers/api/textbooks/piyo_chat_messages_controller.rb new file mode 100644 index 00000000000..a4818411f39 --- /dev/null +++ b/app/controllers/api/textbooks/piyo_chat_messages_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class API::Textbooks::PiyoChatMessagesController < API::BaseController + include TextbookFeatureGuard + before_action :require_textbook_enabled + + def index + messages = current_user.piyo_chat_messages + .where(textbook_section_id: params[:section_id]) + .order(created_at: :asc) + + render json: messages.map { |m| message_json(m) } + end + + def create + section = Textbook::Section.find(params[:section_id]) + + user_message = current_user.piyo_chat_messages.create!( + textbook_section_id: section.id, + role: 'user', + content: params[:content] + ) + + response_text = PiyoChatService.respond( + user: current_user, + section: section, + message: params[:content] + ) + + assistant_message = current_user.piyo_chat_messages.create!( + textbook_section_id: section.id, + role: 'assistant', + content: response_text + ) + + render json: { + user_message: message_json(user_message), + assistant_message: message_json(assistant_message) + }, status: :created + end + + private + + def message_json(message) + { + id: message.id, + role: message.role, + content: message.content, + created_at: message.created_at + } + end +end diff --git a/app/javascript/controllers/piyo_chat_controller.js b/app/javascript/controllers/piyo_chat_controller.js new file mode 100644 index 00000000000..9a1eaa4dc9a --- /dev/null +++ b/app/javascript/controllers/piyo_chat_controller.js @@ -0,0 +1,129 @@ +import { Controller } from 'stimulus' + +export default class extends Controller { + static targets = ['panel', 'messages', 'input', 'sendButton', 'badge'] + static values = { sectionId: Number, open: { type: Boolean, default: false } } + + connect() { + this.loadHistory() + } + + toggle() { + this.openValue = !this.openValue + this.panelTarget.classList.toggle('is-hidden', !this.openValue) + + if (this.openValue) { + this.hideBadge() + this.scrollToBottom() + this.inputTarget.focus() + } + } + + close() { + this.openValue = false + this.panelTarget.classList.add('is-hidden') + } + + async send() { + const content = this.inputTarget.value.trim() + if (!content) return + + this.inputTarget.value = '' + this.appendMessage('user', content) + this.setLoading(true) + + try { + const response = await fetch('/api/textbooks/piyo_chat_messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ + section_id: this.sectionIdValue, + content: content + }) + }) + + if (!response.ok) throw new Error('送信に失敗しました') + + const data = await response.json() + this.appendMessage('assistant', data.assistant_message.content) + } catch (error) { + this.appendMessage('assistant', 'ごめんなさい、エラーが発生しました。もう一度試してみてください。') + } finally { + this.setLoading(false) + } + } + + onKeydown(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + this.send() + } + } + + async loadHistory() { + try { + const response = await fetch( + `/api/textbooks/piyo_chat_messages?section_id=${this.sectionIdValue}` + ) + if (!response.ok) return + + const messages = await response.json() + messages.forEach((msg) => this.appendMessage(msg.role, msg.content)) + } catch { + // 履歴読み込み失敗は無視 + } + } + + appendMessage(role, content) { + const el = document.createElement('div') + el.className = `piyo-chat-message piyo-chat-message--${role}` + el.textContent = content + this.messagesTarget.appendChild(el) + this.scrollToBottom() + + if (!this.openValue && role === 'assistant') { + this.showBadge() + } + } + + setLoading(loading) { + this.sendButtonTarget.disabled = loading + + if (loading) { + this.loadingEl = document.createElement('div') + this.loadingEl.className = 'piyo-chat-message piyo-chat-message--assistant piyo-chat-message--loading' + this.loadingEl.textContent = '...' + this.messagesTarget.appendChild(this.loadingEl) + this.scrollToBottom() + } else if (this.loadingEl) { + this.loadingEl.remove() + this.loadingEl = null + } + } + + scrollToBottom() { + requestAnimationFrame(() => { + this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight + }) + } + + showBadge() { + if (this.hasBadgeTarget) { + this.badgeTarget.classList.remove('is-hidden') + } + } + + hideBadge() { + if (this.hasBadgeTarget) { + this.badgeTarget.classList.add('is-hidden') + } + } + + get csrfToken() { + const meta = document.querySelector('meta[name="csrf-token"]') + return meta ? meta.content : '' + } +} diff --git a/app/models/piyo_chat_message.rb b/app/models/piyo_chat_message.rb new file mode 100644 index 00000000000..4269f5bafc2 --- /dev/null +++ b/app/models/piyo_chat_message.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class PiyoChatMessage < ApplicationRecord + belongs_to :user + belongs_to :section, class_name: 'Textbook::Section', foreign_key: 'textbook_section_id', inverse_of: :piyo_chat_messages + + validates :role, presence: true, inclusion: { in: %w[user assistant] } + validates :content, presence: true + + scope :recent, -> { order(created_at: :asc) } +end diff --git a/app/models/textbook/section.rb b/app/models/textbook/section.rb index d1dddd6110c..9932a14f729 100644 --- a/app/models/textbook/section.rb +++ b/app/models/textbook/section.rb @@ -6,6 +6,7 @@ class Textbook::Section < ApplicationRecord belongs_to :chapter, class_name: 'Textbook::Chapter', foreign_key: 'textbook_chapter_id', inverse_of: :sections has_many :reading_progresses, foreign_key: 'textbook_section_id', dependent: :destroy, inverse_of: :section has_many :term_explanations, foreign_key: 'textbook_section_id', dependent: :destroy, inverse_of: :section + has_many :piyo_chat_messages, foreign_key: 'textbook_section_id', dependent: :destroy, inverse_of: :section validates :title, presence: true validates :body, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index 9683f3274e3..f3a78c7ffee 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -94,6 +94,8 @@ class User < ApplicationRecord # rubocop:todo Metrics/ClassLength belongs_to :course has_many :learnings, dependent: :destroy has_many :reading_progresses, dependent: :destroy + has_many :piyo_chat_messages, dependent: :destroy + has_many :piyo_chat_messages, dependent: :destroy has_many :pages, dependent: :destroy has_many :comments, dependent: :destroy has_many :reports, dependent: :destroy diff --git a/app/services/piyo_chat_service.rb b/app/services/piyo_chat_service.rb new file mode 100644 index 00000000000..79da8626c5d --- /dev/null +++ b/app/services/piyo_chat_service.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class PiyoChatService + SECTION_PROMPT = <<~PROMPT + ## 現在のコンテキスト + ユーザーは教科書のセクションを読んでいます。 + このセクションの内容に基づいて回答してください。 + 回答は簡潔にわかりやすく、セクションの範囲内で説明してください。 + 脱線せず、セクションの学習目標に沿った回答を心がけてください。 + PROMPT + + class << self + def respond(user:, section:, message:) + chat = RubyLLM.chat(model: model_name) + chat.with_instructions(build_prompt(section)) + + history = recent_history(user, section) + history.each do |msg| + chat.ask(msg.content) if msg.role == 'user' + end + + result = chat.ask(message) + result.content.presence || 'ごめんなさい、うまく回答できませんでした。メンターに聞いてみてください。' + rescue StandardError => e + Rails.logger.error("PiyoChatService error: #{e.message}") + 'ごめんなさい、エラーが発生しました。もう一度試してみてください。' + end + + private + + def model_name + ENV.fetch('PJORD_LLM_MODEL', 'gpt-5-mini') + end + + def build_prompt(section) + parts = [Pjord::SYSTEM_PROMPT, SECTION_PROMPT] + parts << textbook_info(section) + parts << goals_section(section) if section.goals.present? && section.goals.any?(&:present?) + parts << terms_section(section) if section.key_terms.present? && section.key_terms.any?(&:present?) + parts.join("\n\n") + end + + def textbook_info(section) + "## 教科書情報\n- 教科書: #{section.chapter.textbook.title}\n- 章: #{section.chapter.title}\n- セクション: #{section.title}" + end + + def goals_section(section) + "## 学習目標\n#{section.goals.select(&:present?).map { |g| "- #{g}" }.join("\n")}" + end + + def terms_section(section) + "## 重要用語\n#{section.key_terms.select(&:present?).join(', ')}" + end + + def recent_history(user, section) + PiyoChatMessage.where(user: user, textbook_section_id: section.id) + .order(created_at: :asc) + .last(10) + end + end +end diff --git a/app/views/shared/_piyo_companion.html.slim b/app/views/shared/_piyo_companion.html.slim index 0ce579eb2d5..a210704c928 100644 --- a/app/views/shared/_piyo_companion.html.slim +++ b/app/views/shared/_piyo_companion.html.slim @@ -1,5 +1,141 @@ -.piyo-companion data-controller='piyo-companion' +- section_id = local_assigns[:section_id] + +.piyo-companion data-controller="piyo-companion #{section_id ? 'piyo-chat' : ''}" data-piyo-chat-section-id-value=section_id + /! チャットパネル(セクション閲覧時のみ) + - if section_id + .piyo-chat-panel.is-hidden data-piyo-chat-target='panel' + .piyo-chat-panel__header + span.piyo-chat-panel__title 🐥 ピヨルドに質問 + button.piyo-chat-panel__close type='button' data-action='click->piyo-chat#close' + i.fa-solid.fa-xmark + .piyo-chat-panel__messages data-piyo-chat-target='messages' + .piyo-chat-panel__input + textarea.piyo-chat-panel__textarea data-piyo-chat-target='input' data-action='keydown->piyo-chat#onKeydown' placeholder='質問を入力...' rows='2' + button.piyo-chat-panel__send data-piyo-chat-target='sendButton' data-action='click->piyo-chat#send' type='button' + i.fa-solid.fa-paper-plane + + /! 吹き出し .piyo-companion__bubble.is-hidden data-piyo-companion-target='bubble' p.piyo-companion__message data-piyo-companion-target='message' - button.piyo-companion__button type='button' data-action='click->piyo-companion#dismiss' - = image_tag 'shared/piyo.svg', alt: 'ピヨルド', class: 'piyo-companion__image' + + /! ピヨルドボタン + - if section_id + button.piyo-companion__button type='button' data-action='click->piyo-chat#toggle' + .piyo-companion__badge.is-hidden data-piyo-chat-target='badge' + = image_tag 'shared/piyo.svg', alt: 'ピヨルド', class: 'piyo-companion__image' + - else + button.piyo-companion__button type='button' data-action='click->piyo-companion#dismiss' + = image_tag 'shared/piyo.svg', alt: 'ピヨルド', class: 'piyo-companion__image' + +css: + .piyo-chat-panel { + position: fixed; + bottom: 80px; + right: 16px; + width: 340px; + max-height: 480px; + background: var(--color-white, #fff); + border: 1px solid var(--color-border, #ddd); + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + z-index: 1001; + overflow: hidden; + } + .piyo-chat-panel.is-hidden { display: none; } + + .piyo-chat-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border, #ddd); + background: var(--color-bg-light, #fafafa); + } + .piyo-chat-panel__title { font-weight: 600; font-size: 14px; } + .piyo-chat-panel__close { + background: none; + border: none; + cursor: pointer; + color: var(--color-muted, #999); + font-size: 16px; + } + + .piyo-chat-panel__messages { + flex: 1; + overflow-y: auto; + padding: 12px; + min-height: 200px; + max-height: 320px; + } + + .piyo-chat-message { + margin-bottom: 8px; + padding: 8px 12px; + border-radius: 12px; + font-size: 14px; + line-height: 1.5; + max-width: 85%; + word-wrap: break-word; + white-space: pre-wrap; + } + .piyo-chat-message--user { + background: var(--color-primary, #4a90d9); + color: #fff; + margin-left: auto; + border-bottom-right-radius: 4px; + } + .piyo-chat-message--assistant { + background: var(--color-bg-light, #f0f0f0); + color: var(--color-text, #333); + margin-right: auto; + border-bottom-left-radius: 4px; + } + .piyo-chat-message--loading { opacity: 0.6; } + + .piyo-chat-panel__input { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--color-border, #ddd); + } + .piyo-chat-panel__textarea { + flex: 1; + border: 1px solid var(--color-border, #ddd); + border-radius: 8px; + padding: 8px; + font-size: 14px; + resize: none; + font-family: inherit; + } + .piyo-chat-panel__textarea:focus { + outline: none; + border-color: var(--color-primary, #4a90d9); + } + .piyo-chat-panel__send { + background: var(--color-primary, #4a90d9); + color: #fff; + border: none; + border-radius: 8px; + width: 36px; + height: 36px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + .piyo-chat-panel__send:disabled { opacity: 0.5; cursor: not-allowed; } + + .piyo-companion__badge { + position: absolute; + top: -2px; + right: -2px; + width: 12px; + height: 12px; + background: #e74c3c; + border-radius: 50%; + border: 2px solid #fff; + } + .piyo-companion__badge.is-hidden { display: none; } diff --git a/app/views/textbooks/sections/show.html.slim b/app/views/textbooks/sections/show.html.slim index 846c4f764d3..8cba0e8d588 100644 --- a/app/views/textbooks/sections/show.html.slim +++ b/app/views/textbooks/sections/show.html.slim @@ -70,4 +70,4 @@ main.page-main i.fa-solid.fa-angle-right.ml-1 /! Piyo companion - = render 'shared/piyo_companion' + = render 'shared/piyo_companion', section_id: @section.id, section_id: @section.id diff --git a/config/routes/textbook.rb b/config/routes/textbook.rb index 78753561764..f7269a953cd 100644 --- a/config/routes/textbook.rb +++ b/config/routes/textbook.rb @@ -11,6 +11,8 @@ namespace :textbooks do resources :reading_progresses, only: %i[create update] resources :term_explanations, only: %i[show] + resources :piyo_chat_messages, only: %i[index create] + resources :piyo_chat_messages, only: %i[index create] end end end diff --git a/db/migrate/20260329000000_create_piyo_chat_messages.rb b/db/migrate/20260329000000_create_piyo_chat_messages.rb new file mode 100644 index 00000000000..9e2ea3fe482 --- /dev/null +++ b/db/migrate/20260329000000_create_piyo_chat_messages.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreatePiyoChatMessages < ActiveRecord::Migration[8.0] + def change + create_table :piyo_chat_messages do |t| + t.references :user, null: false, foreign_key: true + t.references :textbook_section, null: false, foreign_key: { to_table: :textbook_sections } + t.string :role, null: false + t.text :content, null: false + + t.timestamps + end + + add_index :piyo_chat_messages, %i[user_id textbook_section_id created_at], + name: 'index_piyo_chat_messages_on_user_section_created' + end +end diff --git a/db/schema.rb b/db/schema.rb index 6d551bafc2a..d205e52a5c1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_28_000000) do +ActiveRecord::Schema[8.1].define(version: 2026_03_29_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_bigm" enable_extension "pg_catalog.plpgsql" @@ -602,6 +602,18 @@ t.index ["user_id"], name: "index_participations_on_user_id" end + create_table "piyo_chat_messages", force: :cascade do |t| + t.text "content", null: false + t.datetime "created_at", null: false + t.string "role", null: false + t.bigint "textbook_section_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["textbook_section_id"], name: "index_piyo_chat_messages_on_textbook_section_id" + t.index ["user_id", "textbook_section_id", "created_at"], name: "index_piyo_chat_messages_on_user_section_created" + t.index ["user_id"], name: "index_piyo_chat_messages_on_user_id" + end + create_table "practices", id: :serial, force: :cascade do |t| t.integer "category_id" t.datetime "created_at", precision: nil @@ -1237,6 +1249,8 @@ add_foreign_key "pair_works", "users", column: "buddy_id" add_foreign_key "participations", "events" add_foreign_key "participations", "users" + add_foreign_key "piyo_chat_messages", "textbook_sections" + add_foreign_key "piyo_chat_messages", "users" add_foreign_key "practices", "practices", column: "source_id" add_foreign_key "practices_books", "books" add_foreign_key "practices_books", "practices" diff --git a/test/fixtures/piyo_chat_messages.yml b/test/fixtures/piyo_chat_messages.yml new file mode 100644 index 00000000000..c331bce68c4 --- /dev/null +++ b/test/fixtures/piyo_chat_messages.yml @@ -0,0 +1,11 @@ +user_question: + user: hatsuno + textbook_section_id: <%= ActiveRecord::FixtureSet.identify(:section_variables) %> + role: user + content: Linuxって何ですか? + +assistant_answer: + user: hatsuno + textbook_section_id: <%= ActiveRecord::FixtureSet.identify(:section_variables) %> + role: assistant + content: Linuxはオープンソースのオペレーティングシステムだよ! diff --git a/test/models/piyo_chat_message_test.rb b/test/models/piyo_chat_message_test.rb new file mode 100644 index 00000000000..2cabaa11f19 --- /dev/null +++ b/test/models/piyo_chat_message_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PiyoChatMessageTest < ActiveSupport::TestCase + test 'valid with user, section, role, and content' do + message = piyo_chat_messages(:user_question) + assert message.valid? + end + + test 'role must be user or assistant' do + message = piyo_chat_messages(:user_question) + message.role = 'system' + assert_not message.valid? + end + + test 'content is required' do + message = piyo_chat_messages(:user_question) + message.content = '' + assert_not message.valid? + end + + test 'belongs to user' do + message = piyo_chat_messages(:user_question) + assert_equal users(:hatsuno), message.user + end + + test 'belongs to section' do + message = piyo_chat_messages(:user_question) + assert_equal textbook_sections(:section_variables), message.section + end +end diff --git a/test/services/piyo_chat_service_test.rb b/test/services/piyo_chat_service_test.rb new file mode 100644 index 00000000000..b1704c600fe --- /dev/null +++ b/test/services/piyo_chat_service_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PiyoChatServiceTest < ActiveSupport::TestCase + test 'respond returns assistant text' do + section = textbook_sections(:section_variables) + user = users(:hatsuno) + + mock_result = OpenStruct.new(content: 'テスト回答です') + mock_chat = Object.new + mock_chat.define_singleton_method(:with_instructions) { |_| self } + mock_chat.define_singleton_method(:ask) { |_| mock_result } + + RubyLLM.stub(:chat, mock_chat) do + result = PiyoChatService.respond(user: user, section: section, message: 'テスト質問') + assert_equal 'テスト回答です', result + end + end + + test 'respond returns fallback on error' do + section = textbook_sections(:section_variables) + user = users(:hatsuno) + + RubyLLM.stub(:chat, ->(*) { raise StandardError, 'API error' }) do + result = PiyoChatService.respond(user: user, section: section, message: 'テスト') + assert_includes result, 'エラーが発生しました' + end + end +end