Oasist Tech Notes -技術手記-

Tech of the poeple, by the people, for the people

Botpress回答精度検証 in Ruby on Rails

Botpress
Botpress

Contents

1. 環境

2. 要件

このアプリケーションは、以下の二つの要件を満たすUIを提供する。

  1. 訓練データ
  2. 回答精度検証

機能が少ないので、Ruby on Rails を選択する主な選択理由である ActiveRecord やデータベースを持たない非常にシンプルな構成にしている。
さらに、2022年01月16日現在の最新版である Rails 7.0.1 がフロントエンドパッケージの Webpacker疎結合になった。
これにより、このアプリケーションはさらにシンプルになった。

3. ソースコード

3-1. 訓練データ生成

3-1-1. ルーティング

Rails.application.routes.draw do
  # TrainingDataController
  resource :training_data, only: [:new]
  post '/training_data/download', to: 'training_data#download'
  get  '/training_data/download', to: redirect('/training_data/new')
Prefix Verb URI Pattern Controller#Action
new_training_data GET /training_data/new(.:format) training_data#new
training_data_download POST /training_data/download(.:format) training_data#download
GET /training_data/download(.:format) redirect(301, /training_data/new)

3-1-2. コントローラー

3-1-2-1. TrainingDataController

  • TrainingDataController#new はフォームビューに共有する TrainingDataForm クラスインスタンスを生成するのみ。
  • TrainingDataController#downloadtraining_params 経由でCSVフォーマットの訓練データを受け取る。パラメータが有効な場合、#training_data_#{実行時の年月日時分秒}.json のファイル名でJSONフォーマットの訓練データをダウンロードできる。パラメータが不正の場合、"Training data can't be blank" という警告が表示され、TrainingDataControllernewレンダリングし、アクションを抜ける。
class TrainingDataController < ApplicationController
  include Format

  def new
    @training_data_form = TrainingDataForm.new
  end

  def download
    @training_data_form = TrainingDataForm.new(training_params)
    if @training_data_form.valid?
      send_data(to_json(training_params[:training_data]), filename: filename)
    else
      render :new
    end
  end

  private

  def training_params
    params.permit(:training_data)
  end

  def filename
    "training_data_#{DateTime.current.strftime('%F%T').gsub(/[:\-]/, '')}.json"
  end
end

3-1-2-2. Format

Botpress回答精度検証 in Ruby -訓練データ生成- > 3-2-2. Format と全く同じ。

3-1-3. フォームオブジェクト

ApplicationFormvalidates メソッドを利用するため、ActiveModel::Model をインクルードする。

class ApplicationForm
  include ActiveModel::Model
end
  • TrainingDataForm#attr_accessortraining_data 属性の読み込み・書き込みの両方のアクセスを提供する。
  • TrainingDataForm#validatesparams[:training_data] の存在を検証する。もし是である場合、 TrainingDataForm#valid?true を返し、さもなくば false を返す。
class TrainingDataForm < ApplicationForm
  attr_accessor :training_data
  validates :training_data, presence: true
end

3-1-4. ビュー

Create JSON Training Data
Create JSON Training Data

<h1>Create JSON Training Data</h1>
<%= form_with url: training_data_download_path, method: :post do |f| %>
<%= render 'shared/errors', object: @training_data_form %>
  <div class="container">
    <p><%= f.label :training_data %></p>
    <p><%= f.file_field :training_data, accept: '.csv' %></p>
    <p><%= f.submit 'Export JSON', class: 'btn btn-primary' %></p>
  </div>
<% end %>

3-1-5. テスト

  • CSVフォーマットの訓練データが適切に選択されている場合、JSONフォーマットの訓練データのダウンロードに成功する。
  • CSVフォーマットの訓練データが適切に選択されていない場合、"Training data can't be blank" というバリデーションエラーが表示される。
require 'rails_helper'

RSpec.describe "TrainingData", type: :system do
  before do
    visit new_training_data_path
  end

  describe 'training data conversion from CSV to JSON' do
    context 'A CSV file is chosen' do
      it 'succeeds in converting CSV file to JSON file' do
        expect(page).to have_current_path new_training_data_path
        attach_file 'Training data', "#{Rails.root}/csv/training_data.csv"
        click_on 'Export JSON'
        expect(downloaded_file).to match(/training_data.*json/)
      end
    end

    context 'No CSV file is chosen' do
      it 'fails to convert CSV file to JSON file with a validation message shown' do
        expect(page).to have_current_path new_training_data_path
        click_on 'Export JSON'
        expect(page).to have_selector '.alert-danger', text: "Training data can't be blank"
      end
    end
  end
end

3-2. 回答精度検証

3-2-1. ルーティング

Rails.application.routes.draw do
  # ScoreChartController
  resource :score_chart, only: [:new, :create], controller: :score_chart
  get '/score_chart/draw',     to: 'score_chart#draw'
  get '/score_chart/download', to: 'score_chart#download', default: { format: :csv }
  get '/score_chart',          to: redirect('/score_chart/new')
end
Prefix Verb URI Pattern Controller#Action
new_score_chart GET /score_chart/new(.:format) score_chart#new
score_chart POST /score_chart(.:format) score_chart#create
score_chart_draw GET /score_chart/draw(.:format) score_chart#draw
score_chart_download GET /score_chart/download(.:format) score_chart#download {:default=>{:format=>:csv}}
GET /score_chart(.:format) redirect(301, /score_chart/new)

3-2-2. コントローラー

3-2-2-1. ScoreChartController

  • ScoreChartController#new はフォームビューに共有する ScoreChartForm クラスインスタンスを生成するのみ。このアクションが呼ばれるとき、before_action で指定された ChartManager#clear_tmp_chartstmp/downloads 内に保存された全ての一時保存されたCSVフォーマットのマトリックス図を削除するコールバック関数として発火する。
  • ScoreChartController#create は以下の処理を行う。
    • schemehostbot_iduser_idaccess_tokentest_data を含む test_params を受け取る。もしパラメータが有効な場合、次の処理に進む。さもなくば、ScoreChartController#newレンダリングし、アクションを抜ける。
      • test_params を引数に取り ScoreChartQuery クラスインスタンスを生成し、ScoreChartQuery#res_bodies を実行する。成功の場合、Rubyのハッシュにパースしたレスポンスボディの配列を返す。さもなくば、"Host is invalid" という警告を表示し、ScoreChartController#newレンダリングし、アクションを抜ける。
      • test_params[:test_data]res_bodies を引数に取り ChartDrawer クラスインスタンスを生成し、 ChartDrawer#csv を実行する。成功の場合、CSVフォーマットのマトリックス図を返す。さもなくば、"BotID、UserID or AccessToken is invalid" という警告を表示し、ScoreChartController#newレンダリングし、アクションを抜ける。
      • CSVフォーマットのマトリックス図のデータを ScoreChartController#draw アクションと共有するため一時保存する。アクション間の変数の共有の目的でクラス変数 @@csv_chart を使うのは、典型的なアンチパターンなので避けるべきである。
      • このアクションでHTMLフォーマットのマトリックス図を描画すると、ページリロードに 404 エラーになってしますう。これは、このアクションが POST メソッドで行われており、リロード時は GET メソッドでルーティングを参照するからである。そういう訳で。 GET メソッドでアクセスする別のパスを切り、そちらにリダイレクトした上でHTMLフォーマットのマトリックス図を描画する。
  • ScoreChartController#draw は、一時保存されたCSVフォーマットのマトリックス図が存在する場合に、文字通りHTMLフォーマットのマトリックス図を描画する。さもなくば、ScoreChartController#newレンダリングし、アクションを抜ける。
  • ScoreChartController#download では、一時保存されたCSVフォーマットのマトリックス図が存在する場合に、文字通りCSVフォーマットのマトリックス図を #accuracy_score_chart_#{実行時の年月日時分秒}.csv のファイル名でダウンロードできる。さもなくば、ScoreChartController#newレンダリングし、アクションを抜ける。
class ScoreChartController < ApplicationController
  include ChartManager
  before_action :clear_tmp_charts, only: [:new]

  def new
    @score_chart_form = ScoreChartForm.new
  end

  def create
    @score_chart_form = ScoreChartForm.new(test_params)
    if @score_chart_form.valid?
      score_chart_query = ScoreChartQuery.new(test_params)
      begin
        res_bodies = score_chart_query.res_bodies
      rescue SocketError
        flash[:alert] = 'Host is invalid'
        render :new and return
      end

      chart_drawer = ChartDrawer.new(test_params[:test_data], res_bodies)
      begin
        csv_chart = chart_drawer.csv
      rescue NoMethodError
        flash[:alert] = 'BotID, UserID or AccessToken is invalid'
        render :new and return
      end

      save_chart(filename, csv_chart)
      redirect_to score_chart_draw_url
    else
      render :new
    end
  end

  def draw
    redirect_to new_score_chart_url and return unless tmp_chart
    @chart = matrix_chart
  end

  def download
    redirect_to new_score_chart_url and return unless tmp_chart
    send_file(tmp_chart, filename: filename)
  end

  private

  def test_params
    params.permit(:scheme, :host, :bot_id, :user_id, :access_token, :test_data)

  def filename
    "accuracy_score_chart_#{DateTime.current.strftime('%F%T').gsub(/[:\-]/, '')}.csv"
  end
end

3-2-2-2. ChartManager

  • CSVフォーマットのマトリックス図を一時保存するパスである tmp/downloadsPATH 定数に代入する。
  • ChartManager#save_charttmp/downloads が存在しない場合はそれを作り、filenamecsv_chart を引数に取りCSVフォーマットのマトリックス図を一時保存する。
  • ChartManager#tmp_chartstmp/downloads 内の accuracy_score_chart_#{YYMMDDhhmmss}.csv に合致する全てのファイル名を返す。
  • ChartManager#tmp_charttmp/downloads 内の 最後の要素を返す。
  • ChartManager#matrix_charttmp/downloads 内にCSVフォーマットのマトリックス図があればそれを返す。
  • ChartManager#clear_tmp_chartstmp/downloads 内の全てのファイルを削除する。これは ScoreChartController#new アクションで呼ばれる。
require 'csv'

module ChartManager
  PATH = Rails.root.join('tmp', 'downloads')

  def save_chart(filename, csv_chart)
    Dir.mkdir(PATH) unless Dir.exist?(PATH)
    File.open(PATH.join(filename), 'w') { |f| f.puts(csv_chart) }
  end

  def tmp_charts
    Dir[PATH.join('accuracy_score_chart*.csv')]
  end

  def tmp_chart
    tmp_charts.last
  end

  def matrix_chart
    CSV.open(tmp_chart) { |f| f.read } if tmp_chart
  end

  def clear_tmp_charts
    FileUtils.rm_f(tmp_charts) if tmp_charts.any?
  end
end

3-2-3. フォームオブジェクト

  • TrainingDataForm#attr_accessorschemehostbot_iduser_idaccess_tokentest_data 属性の読み込み・書き込みの両方アクセスを提供する。
  • TrainingDataForm#validates params[:scheme]params[:host]params[:bot_id]params[:user_id]params[:access_token]params[:test_data] の存在を検証する。もし是である場合、TrainingDataForm#valid?true を返し、さもなくば false を返す。
class ScoreChartForm < ApplicationForm
  attr_accessor :scheme, :host, :bot_id, :user_id, :access_token, :test_data
  validates :scheme,       presence: true
  validates :host,         presence: true
  validates :bot_id,       presence: true
  validates :user_id,      presence: true
  validates :access_token, presence: true
  validates :test_data,    presence: true
end

3-2-4. クエリ

Botpress回答精度検証 in Ruby -回答精度検証- > 3-2-2. AccuracyCheckQuery とほぼ同じだが、以下の差分がある。

  • ScoreChartQuery#initializetest_params を引数にとり、各パラメータをキーで参照している。
  • params[:access_token](Bearerトークン)がユーザー入力である。
...

class ScoreChartQuery

  INVALID_PATTERNS = /[\\\'\|\`\^\"\<\>\)\(\}\{\]\[\;\/\?\:\@\&\=\+\$\,\%\# ]/

  def initialize(test_params)
    @scheme       = test_params[:scheme]
    @host         = test_params[:host].gsub(INVALID_PATTERNS, '')
    @bot_id       = test_params[:bot_id].gsub(INVALID_PATTERNS, '')
    @user_id      = test_params[:user_id].gsub(INVALID_PATTERNS, '')
    @access_token = test_params[:access_token].chomp
    @test_data    = CSV.read(test_params[:test_data], headers: true)
  end

...

3-2-5. ライブラリ

Botpress回答精度検証 in Ruby -回答精度検証- > 3-2-3. ChartDrawer と全く同じ。

3-2-6. ビュー

3-2-6-1. Create Score Chart

Create Score Chart
Create Score Chart

<h1>Create Score Chart</h1>
<%= form_with url: score_chart_url do |f| %>
<%= render 'shared/errors', object: @score_chart_form %>
  <div class="form-item">
    <p><strong><%= f.label :scheme %></strong></p>
    <p><%= f.select :scheme, ['https', 'http'], { selected: 'https' } %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :host %></strong></p>
    <p><%= f.text_field :host, size: 50, placeholder: 'sample.com' %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :bot_id %></strong></p>
    <p><%= f.text_field :bot_id, size: 30, placeholder: 'sample-bot' %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :user_id %></strong></p>
    <p><%= f.text_field :user_id, size: 30, placeholder: 'sample-user' %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :access_token %></strong></p>
    <p><%= f.text_area :access_token, size: '100x5', placeholder: 'Bearer ...' %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :test_data %></strong></p>
    <p><%= f.file_field :test_data, accept: '.csv' %></p>
  </div>
  <div class="form-item">
    <p><%= f.submit 'Create Score Chart', class: 'btn btn-primary' %></p>
  </div>
<% end %>

3-2-6-2. Accuracy Score Chart

Accuracy Score Chart
Accuracy Score Chart

<h1>Accuracy Score Chart</h1>
<p><%= link_to 'Download Score Chart', score_chart_download_url, class: 'btn btn-primary' %></p>
<div class="scroll-table">
  <table>
    <% @chart&.each do |chart| %>
      <tr>
        <% chart&.each do |row| %>
          <th class=<%= accuracy(row) %>><%= row %></th>
        <% end %>
      </tr>
    <% end %>
  </table>
</div>

<div class="annotaion">
  <h2>Annotation</h2>
  <p class="excellent">Greater than or equal to 70.0%</p>
  <p class="good">Greater than or equal to 50.0% and less than 70.0%</p>
  <p class="bad">Greater than or equal to 30.0% and less than 50.0%</p>
  <p class="useless">Greater than or equal to 0.1% and less than 30.0%</p>
</div>

3-2-7. Helper

ScoreChartHelper#accuracy は回答精度を評価し、レンジに応じてセルの色分けを行う。

  • 70.0%以上: 青
  • 50.0%以上70%未満: 緑
  • 30.0%以上50%未満: 黄色
  • 30.0%未満: 赤
  • 0%もしくは回答精度と無関係のセル: デフォルトの背景色(白)

3-2-8. テスト

  • 全てのパラメータが有効な場合、, HTMLフォーマットのマトリックス図を描画し、その行数がCSVフォーマットのテストデータの行数と一致する。
  • 一時保存されたCSVフォーマットのマトリックス図が存在する場合、ダウンロードが成功しファイル名が accuracy_score_chart_#{実行時の年月日時分秒}.csv に一致する。
  • new_score_chart_path にアクセスすると、tmp/downloads の全てのファイルが削除される。
  • 一時保存されたCSVフォーマットのマトリックス図が存在しない場合、score_chart_draw_path にアクセスすると new_score_chart_path にリダイレクトする。
  • 一時保存されたCSVフォーマットのマトリックス図が存在しない場合、score_chart_download_path にアクセスすると new_score_chart_path にリダイレクトする。
  • パラメータが一つも与えられない場合、以下のバリデーションエラーが発生する。
    • "Host can't be blank"
    • "Bot can't be blank"
    • "User can't be blank"
    • "Access token can't be blank"
    • "Test data can't be blank"
  • ホスト名が不正な場合、"Host is invalid" という警告が表示される。
  • ボットID、ユーザーID、アクセストークンのいずれかが不正な場合、"BotID, UserID or AccessToken is invalid" という警告が表示される。
require 'rails_helper'
require 'csv'
require 'fileutils'

RSpec.describe "ScoreChart"type: :system do
  before do
    visit new_score_chart_path
  end

  describe 'HTML accuracy score chart' do
    context 'All required parameters are fulfilled' do
      let(:number_of_test_data) { CSV.read("#{Rails.root}/csv/test_data.csv").size }

      before do
        select 'https'from: 'Scheme'
        fill_in 'Host'with: ENV['HOST']
        fill_in 'Bot'with: ENV['BOT_ID']
        fill_in 'User'with: ENV['USER_ID']
        fill_in 'Access token'with: ENV['ACCESS_TOKEN']
        attach_file 'Test data'"#{Rails.root}/csv/test_data.csv"
        click_on 'Create Score Chart'
      end

      it 'succeeds in rendering HTML accuracy score chart' do
        expect(page).to have_current_path score_chart_draw_path
        expect(all('tbody tr').size).to eq(number_of_test_data)
      end
    end
  end

  describe 'CSV accuracy score chart' do
    context 'Any temporary CSV accuracy score chart exists in ./tmp/downloads' do
      before do
        FileUtils.cp "#{Rails.root}/csv/accuracy_score_chart_20211130220255.csv""#{Rails.root}/tmp/downloads/"
      end

      it 'succeeds in downloading CSV accuracy score chart' do
        visit score_chart_download_path
        expect(downloaded_file).to match(/accuracy_score_chart.*csv/)
      end

      it 'clears all tmp_charts when visiting new_score_chart_path' do
        visit new_score_chart_path
        expect(downloads).to eq([])
      end
    end

    context 'No temporary CSV accuracy score chart exists in ./tmp/downloads' do
      it 'redirects to new_score_chart_path' do
        visit new_score_chart_path
        expect(page).to have_current_path new_score_chart_path
      end
    end
  end

  describe 'validation and error handling' do
    context 'No required parameters are fulfilled' do
      before do
        click_on 'Create Score Chart'
      end

      it 'fails to render HTML accuracy score chart with validation errors shown' do
        expect(page).to have_current_path score_chart_path
        expect(page).to have_selector '.alert-danger'text: "Host can't be blank"
        expect(page).to have_selector '.alert-danger'text: "Bot can't be blank"
        expect(page).to have_selector '.alert-danger'text: "User can't be blank"
        expect(page).to have_selector '.alert-danger'text: "Access token can't be blank"
        expect(page).to have_selector '.alert-danger'text: "Test data can't be blank"
      end
    end

    context 'Invalid Host' do
      before do
        select 'https'from: 'Scheme'
        fill_in 'Host'with: 'foo'
        fill_in 'Bot'with: ENV['BOT_ID']
        fill_in 'User'with: ENV['USER_ID']
        fill_in 'Access token'with: ENV['ACCESS_TOKEN']
        attach_file 'Test data'"#{Rails.root}/csv/test_data.csv"
        click_on 'Create Score Chart'
      end

      it 'fails in rendering HTML matrix chart and an error message is shown' do
        expect(page).to have_current_path score_chart_path
        expect(page).to have_selector '.alert-danger'text: 'Host is invalid'
      end
    end

    context 'Invalid BotID、UserID and AccessToken' do
      before do
        select 'https'from: 'Scheme'
        fill_in 'Host'with: ENV['HOST']
        fill_in 'Bot'with: 'foo'
        fill_in 'User'with: 'bar'
        fill_in 'Access token'with: 'piyo'
        attach_file 'Test data'"#{Rails.root}/csv/test_data.csv"
        click_on 'Create Score Chart'
      end

      it 'fails in rendering HTML matrix chart and an error message is shown' do
        expect(page).to have_current_path score_chart_path
        expect(page).to have_selector '.alert-danger'text: 'BotID、UserID or AccessToken is invalid'
      end
    end
  end
end

4. GitHubリポジトリ