diff --git a/app/assets/stylesheets/shared/blocks/_piyo-companion.css b/app/assets/stylesheets/shared/blocks/_piyo-companion.css index 026480c4d55..b29cde6c99c 100644 --- a/app/assets/stylesheets/shared/blocks/_piyo-companion.css +++ b/app/assets/stylesheets/shared/blocks/_piyo-companion.css @@ -11,68 +11,164 @@ cursor: pointer; padding: 0; position: relative; - transition: transform 0.15s; -} - -.piyo-companion__button:hover { - transform: scale(1.1); } .piyo-companion__image { - width: 56px; - height: 56px; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15)); + width: 64px; + height: 64px; } .piyo-companion__bubble { position: absolute; - bottom: 64px; + bottom: 72px; right: 0; - background: var(--base, #fff); - border: 1px solid var(--border, #e0e0e0); + background: #fff; + border: 1px solid #e0e0e0; border-radius: 12px; - padding: 0.625rem 0.875rem; - max-width: 220px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); - animation: piyo-bubble-in 0.2s ease-out; + 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: -7px; - right: 20px; + bottom: -8px; + right: 24px; width: 0; height: 0; - border-left: 7px solid transparent; - border-right: 7px solid transparent; - border-top: 7px solid var(--base, #fff); + 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: -3px; - right: -3px; - width: 10px; - height: 10px; - background: var(--danger, #ef4444); + top: -4px; + right: -4px; + width: 12px; + height: 12px; + background: #ef4444; border-radius: 50%; - border: 2px solid var(--base, #fff); -} - -@keyframes piyo-bubble-in { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } + 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 eafe9657444..5dc800ae9a0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -93,6 +93,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 235e0d4c728..a210704c928 100644 --- a/app/views/shared/_piyo_companion.html.slim +++ b/app/views/shared/_piyo_companion.html.slim @@ -1,10 +1,141 @@ - section_id = local_assigns[:section_id] -.piyo-companion data-controller='piyo-companion' +.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 735e94a9f39..b15772fe4a0 100644 --- a/app/views/textbooks/sections/show.html.slim +++ b/app/views/textbooks/sections/show.html.slim @@ -1,14 +1,5 @@ - title "#{@textbook.title} - #{@section.title}" -ruby: - sections = @chapter.sections.to_a - current_index = sections.index(@section) - progress_percent = ((current_index + 1).to_f / sections.size * 100).round - all_sections = @textbook.chapters.flat_map(&:sections) - global_index = all_sections.index(@section) - prev_section = global_index.positive? ? all_sections[global_index - 1] : nil - next_section = global_index < all_sections.size - 1 ? all_sections[global_index + 1] : nil - main.page-main header.page-main-header .container @@ -24,65 +15,59 @@ main.page-main | 目次に戻る hr.a-border .page-body - .page-body__inner.has-side-nav - .container.is-md - /! パンくず - nav.textbook-breadcrumb - = link_to @textbook.title, textbook_path(@textbook) - | › - = " 第#{@chapter.position}章 #{@chapter.title}" - | › - span = " #{current_index + 1}/#{sections.size}" - - /! 学習目標 - - if @section.goals.present? && @section.goals.any?(&:present?) - .a-card.is-sm - header.card-header.is-sm - h2.card-header__title - i.fa-solid.fa-bullseye - | このセクションの目標 - .card-body - ul.card-body__items - - @section.goals.each do |goal| - - next if goal.blank? - li.card-body__item - = goal + .container.is-md + /! Progress bar + ruby: + sections = @chapter.sections.to_a + current_index = sections.index(@section) + progress_percent = ((current_index + 1).to_f / sections.size * 100).round + .mb-2 + .flex.items-center.gap-2 + span.text-muted + = "#{@chapter.title} (#{current_index + 1}/#{sections.size})" + .progress-bar.mt-1 + .progress-bar__fill style="width: #{progress_percent}%" - /! 本文 - .a-card data-controller='reading-progress' data-reading-progress-section-id-value="#{@section.id}" data-reading-progress-total-blocks-value='0' + /! Section goals + - if @section.goals.present? && @section.goals.any?(&:present?) + .a-card.mb-2 + header.card-header + h2.card-header__title + i.fa-solid.fa-bullseye.mr-1 + | このセクションの目標 + hr.a-border-tint .card-body - .card-body__description - .a-long-text.is-md.js-markdown-view - = textbook_section_body(@section) + ul.card-body__items + - @section.goals.each do |goal| + - next if goal.blank? + li.card-body__item + = goal + + /! Main content + .a-card data-controller='reading-progress' data-reading-progress-section-id-value="#{@section.id}" data-reading-progress-total-blocks-value='0' + .card-body + .card-body__description + .a-long-text.is-md.js-markdown-view + = textbook_section_body(@section) - /! 前後ナビ - .page-content-prev-next - .page-content-prev-next__item - - if prev_section - = link_to textbook_chapter_section_path(@textbook, prev_section.chapter, prev_section), class: 'page-content-prev-next__item-link is-prev' do - i.fa-solid.fa-angle-left - | 前へ - .page-content-prev-next__item - - if next_section - = link_to textbook_chapter_section_path(@textbook, next_section.chapter, next_section), class: 'page-content-prev-next__item-link is-next' do - | 次へ - i.fa-solid.fa-angle-right + /! Previous/Next navigation + ruby: + all_sections = @textbook.chapters.flat_map(&:sections) + global_index = all_sections.index(@section) + prev_section = global_index.positive? ? all_sections[global_index - 1] : nil + next_section = global_index < all_sections.size - 1 ? all_sections[global_index + 1] : nil - /! サイドナビ(章のセクション一覧) - nav.a-side-nav - .a-side-nav__inner - header.a-side-nav__header - h2.a-side-nav__title - = link_to textbook_path(@textbook), class: 'a-side-nav__title-link' do - = "第#{@chapter.position}章 #{@chapter.title}" - hr.a-border - .a-side-nav__body - ul.a-side-nav__items - - sections.each do |section| - li.a-side-nav__item class=("#{'is-current' if section == @section}") - = link_to textbook_chapter_section_path(@textbook, @chapter, section), class: 'a-side-nav__item-link' do - span.a-side-nav__item-link-inner - = section.title + .page-content-prev-next.mt-2 + .page-content-prev-next__item + - if prev_section + = link_to textbook_chapter_section_path(@textbook, prev_section.chapter, prev_section), class: 'page-content-prev-next__item-link is-prev' do + i.fa-solid.fa-angle-left.mr-1 + = prev_section.title + .page-content-prev-next__item + - if next_section + = link_to textbook_chapter_section_path(@textbook, next_section.chapter, next_section), class: 'page-content-prev-next__item-link is-next' do + = next_section.title + i.fa-solid.fa-angle-right.ml-1 - /! ピヨルド + /! Piyo companion = render 'shared/piyo_companion', 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 fc5bfd87a48..0936cde80df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -593,6 +593,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 @@ -1234,6 +1246,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