Oasistブログ

言語学、エンジニアリング、ライフ記事を気まぐれにお届け

Botpress性能検証自動化 Vol.3 - Railsアプリケーション -

f:id:oasist:20201024112839p:plain
Botpress

Contents

1. はじめに

以前、Botpress性能検証自動化 Vol.1 - CSV → JSONコンバーター -Botpress性能検証自動化 Vol.2 - Q&A確信度マトリックス図自動生成 -でそれぞれの機能についてPythonRubyで実装したコードを紹介した。
それらをWebアプリケーションで実現するため、Ruby on Rails(以下Rails)アプリケーションを実装した。

まず初めに、このアプリケーションはDBへのアクセスを一切行わず、他のいくつかの標準機能についてもしようしないので、以下のようにオプション付きでRailsアプリケーションを生成した。

$ rails new botpress_inspection_tool_kit_rails -d -M -O -C -T

それぞれのオプションの意味は rails new --help で参照頂きたい。


早い話が、「MVC」モデルではなく、「VC」モデルでの実装である。
そういうわけで、いわゆる The Rails Way から外れる箇所もいくつかある。
自分自身も好んでいるわけではなく、ゆくゆくは別の軽量フレームワークに代替したいと考えている。

本記事では実装について言及するので、Railsアプリケーションの実際の使い方はこちらの手順書を参照頂きたい。

2. 前提条件

  1. Botpressサーバーを構築済みかつ学習データのトレーニングも完了している(未完了の方はReady Botpressを参照)。
  2. "Bot ID"、"User ID"、"Bearer Token"が分かる状態である(未完了の方はPrepare for Required Parametersを参照)。
  3. Botpress Inspection Tool Kitにブラウザでアクセス可能である。

3. ルーティング

ユースケースCRUDではないので、resourceresources メソッドは使用せず、べた書きにした。
最後の2行はPOSTメソッドでアクセスするページにリロード等でGETメソッドでアクセスされた場合、それぞれの前の画面にリダイレクトする処理を定義している。

config/routes.rb

Rails.application.routes.draw do
  root 'top#index'
  get  '/top'                          => 'top#index'
  get  '/json-converters/select-csv'   => 'json_converters#select_csv'
  post '/json-converters/download'     => 'json_converters#download'
  get  '/converse-api/select-data'     => 'converse_api#select_data'
  post '/converse-api/generate-matrix' => 'converse_api#generate_matrix'
  post '/converse-api/export-matrix'   => 'converse_api#export_matrix', default: { format: :csv }

  get '/json-converters/download'     => redirect('/json-converters/select-csv')
  get '/converse-api/generate-matrix' => redirect('/converse-api/select-data')
end
                      Prefix Verb URI Pattern                             Controller#Action
                        root GET  /                                       top#index
                         top GET  /top(.:format)                          top#index
  json_converters_select_csv GET  /json-converters/select-csv(.:format)   json_converters#select_csv
    json_converters_download POST /json-converters/download(.:format)     json_converters#download
    converse_api_select_data GET  /converse-api/select-data(.:format)     converse_api#select_data
converse_api_generate_matrix POST /converse-api/generate-matrix(.:format) converse_api#generate_matrix
  converse_api_export_matrix POST /converse-api/export-matrix(.:format)   converse_api#export_matrix {:default=>{:format=>:csv}}
                             GET  /json-converters/download(.:format)     redirect(301, /json-converters/select-csv)
                             GET  /converse-api/generate-matrix(.:format) redirect(301, /converse-api/select-data)

4. JSONコンバーター

4-1. コントローラー

JsonConvertersController#select_csv は学習データを送信するフォーム表示のためのアクションである。

JsonConvertersController#download は学習データCSVをBoptressのQ&Aインポートに最適化されたJSONフォーマットに変換するアクションである。
ファイルが選択されていない場合、"Choose a learning data csv." というエラーメッセージが表示される。

app/controllers/json_converters_controller.rb

require 'csv'
require 'json'

class JsonConvertersController < ApplicationController
  include JsonGenerator

  def select_csv
  end

  def download
    if csv_learning_data = file_params[:csv_learning_data]
      json_learning_data = generate_json_file(csv_learning_data)
      send_data(
        json_learning_data,
        filename: "learning_data_#{DateTime.current.strftime('%F%T').gsub('-', '').gsub(':', '')}.json"
      )
    else
      flash[:alert] = 'Choose a learning data csv.'
      render :select_csv
    end
  end

  private

  def file_params
    params.permit(:csv_learning_data)
  end
end

JsonGenerator#gen_hash_template はハッシュオブジェクトのテンプレートを生成する。

JsonGenerator#generate_json_file はQA番号を id キーに代入し、入力文を questions > ja キーに、回答文を answers > ja に追加する。
重複を取り除いた後、JSONフォーマットに変換する。

app/controllers/concerns/json_generator.rb

module JsonGenerator
  def gen_hash_template
    {
      id: '',
      data: {
        action: 'text',
        contexts: [
          'hoge'
        ],
        enabled: true,
        answers: {
          ja: []
        },
        questions: {
          ja: []
        },
        'redirectFlow': '',
        'redirectNode': ''
      }
    }
  end

  def generate_json_file(csv_learning_data)
    learning_data = []
    hash_template = gen_hash_template
    CSV.foreach(csv_learning_data, headers: true) do |learning_datum|
      if hash_template[:data][:answers][:ja].last == learning_datum['Answers']
        hash_template[:data][:questions][:ja] << learning_datum['Questions']
      else
        hash_template = gen_hash_template
        hash_template[:id] = learning_datum['Serial_Nums']
        hash_template[:data][:questions][:ja] << learning_datum['Questions']
        hash_template[:data][:answers][:ja] << learning_datum['Answers']
      end
      hash_template[:data][:questions][:ja].uniq!
      learning_data << hash_template
    end
    JSON.dump({ qnas: learning_data.uniq })
  end
end

4-2. ビュー

送信された学習データCSVJsonConvertersController#download アクションが処理する。
"Export JSON" をクリックすることで、JSONフォーマットの学習データをダウンロードできる。

<h1>Learning Data Converter from CSV to JSON</h1>
<%= form_with url: json_converters_download_path, method: :post, multipart: true do |f| %>
  <div class="container">
    <p><%= f.label :csv_learning_data, 'Choose CSV Learning Data' %></p>
    <p><%= f.file_field :csv_learning_data, accept: '.csv' %></p>
    <p><%= f.submit 'Export JSON', class: 'btn btn-primary' %></p>
  </div>
<% end %>

5. Converse API

5-1. コントローラー

ConverseApiController#select_data は各パラメーターとテストデータCSVを送信するフォームを表示するためのアクションである。

ConverseApiController#generate_matrix はテストデータCSVCSVフォーマットの確信度マトリックス図に変換し、それをHTMLレンダリングするアクションである。
パラメーターが1つでも欠けていると、コールバック関数 alert_lacking_form_params によって、"Fill in values or choose files in each field." というエラーメッセージが表示される。

ConverseApiController#export_matrix 確信度マトリックス図をCSVフォーマットでダウンロードする機能を提供する。

お気づきの方もいらっしゃると思うが、ConverseApiController#generate_matrixConverseApiController#export_matrix のアクション間で値を共有するために @@csv_data というクラス変数を使っている。
基本的にクラス変数は、クラスのどこからでも参照や上書きが可能なため、思わぬバグを埋め込む可能性を孕んでいる。
コールバック関数で値の共有を実装しても同じ処理を複数回行う実装になるため、最も簡単な方法をタブーを破って採用した("VC"モデルでの実現のため)。

言い訳だが、最も取りたくない手段であった。

app/controllers/converse_api_controller.rb

require 'net/http'
require 'json'
require 'csv'

class ConverseApiController < ApplicationController
  include ApiCaller
  include MatrixGenerator

  before_action :alert_lacking_form_params, only: %i(generate_matrix)

  def select_data
  end

  def generate_matrix
    url, req = authenticate(
      converse_api_params[:protocol],
      converse_api_params[:host],
      converse_api_params[:bot_id],
      converse_api_params[:user_id],
      converse_api_params[:bearer_token]
    )
    @@csv_data = CSV.generate do |csv|
      test_data = CSV.read(converse_api_params[:csv_test_data], headers: true)
      csv << set_header(test_data['Serial_Nums'])
      answers_arr = test_data['Answers']
      test_data.each do |test_datum|
        begin
          res = get_api_response(test_datum['Questions'], url, req)
        rescue SocketError
          flash[:alert] = 'It failed to successfully create a matrix chart. Input correct Host.'
          return
        end
        begin
          csv << set_row(test_datum, answers_arr, res.body)
        rescue NoMethodError
          flash[:alert] = 'It failed to successfully create a matrix chart. Input correct Bot ID, User ID and Bearer Token.'
          return
        end
      end
    end
    @matrix = @@csv_data.split("\n").map { |str| str.split(',') }
  end

  def export_matrix
    send_data(
      @@csv_data,
      filename: "matrix_#{DateTime.current.strftime('%F%T').gsub('-', '').gsub(':', '')}.csv"
    )
  end

  private

  def converse_api_params
    params.permit(
      :protocol,
      :host,
      :bot_id,
      :user_id,
      :bearer_token,
      :csv_test_data
    )
  end

  def alert_lacking_form_params
    unless converse_api_params[:protocol] && \
          converse_api_params[:host] && \
          converse_api_params[:bot_id] && \
          converse_api_params[:user_id] && \
          converse_api_params[:bearer_token] && \
          converse_api_params[:csv_test_data]
      flash[:alert] = 'Fill in values or choose files in each field.'
      render :select_data
      return
    end
  end
end

app/controllers/concerns/api_caller.rb

ApiCaller#authenticate はURLとリクエストを認証付きで返す。

get_api_response はURLとリクエスト、入力文を受け取りConverse APIをコールする。
プロトコルhttps の場合は、SSL通信で実行する。

module ApiCaller
  def authenticate(protocol, host, bot_id, user_id, bearer_token)
    url = URI.parse("#{protocol}://#{host}/api/v1/bots/#{bot_id}/converse/#{user_id}/secured?include=state,suggestions")
    req = Net::HTTP::Post.new(url)
    req[:authorization] = bearer_token
    return url, req
  end

  def get_api_response(question, url, req)
    req.set_form_data(type: :text, text: question)
    net_http = Net::HTTP.new(url.host, url.port)
    net_http.use_ssl = true if url.to_s.include?('https')
    res = net_http.start { |http| http.request(req) }
  end
end

MatrixGenerator#set_header["Serial_Nums", "Test_Data"] とテストデータCSV内のQA番号を結合して文字通りマトリックス図のヘッダーを作成する。

MatrixGenerator#set_answers_confidence{ 質問文: 確信度 } のハッシュオブジェクトを作成する。

MatrixGenerator#set_row は QA番号とテスト入力文、MatrixGenerator#set_answers_confidence が提供する確信度を結合して文字通り行を生成する。

app/controllers/concerns/matrix_generator.rb

module MatrixGenerator
  def set_header(serial_nums)
    header = %w(Serial_Nums Test_Data)
    header.concat(serial_nums)
  end

  def set_answers_confidence(res_body)
    res_hash = JSON.parse(res_body)
    answers_confidence = 0.upto(res_hash.dig('suggestions').size - 1).map do |i|
      { res_hash.dig('suggestions')[i].dig('payloads')[1].dig('text') => res_hash.dig('suggestions')[i].dig('confidence') }
    end
  end

  def set_row(test_datum, answers_arr, res_body)
    row = [test_datum['Serial_Nums'], test_datum['Questions']]
    last_index = answers_arr.size - 1
    confidence = [].fill('0.0%', 0..last_index)
    answers_confidence = set_answers_confidence(res_body)
    answers_confidence.each do |ans_conf|
      index = answers_arr.find_index(ans_conf.keys.first)
      confidence[index] = "#{sprintf('%.1f', ans_conf.values.first * 100)}%"
    end
    row.concat(confidence)
  end
end

5-2. ビュー

各パラメーターとテストデータCSVConverseApiController#export_matrix アクションによって処理される。
"Generate Matrix Chart"をクリックすると画面遷移の後、マトリックス図が表示される。

app/views/converse_api/select_data.html.erb

<h1>Generate Matrix Chart of Confidence</h1>
<%= form_with url: converse_api_generate_matrix_path, method: :post, multipart: true do |f| %>
  <div class="form-item">
    <p><strong><%= f.label :protocol, 'Protocol' %></strong></p>
    <p><%= f.select :protocol, [['http'], ['https']], { selected: 'http' } %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :host, 'Host' %></strong></p>
    <p class="note">* Port is required in the local environment</p>
    <p><%= f.text_field :host %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :bot_id, 'Bot ID' %></strong></p>
    <p><%= f.text_field :bot_id %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :user_id, 'User ID' %></strong></p>
    <p><%= f.text_field :user_id %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :bearer_token, 'Bearer Token' %></strong></p>
    <p><%= f.text_area :bearer_token, size: '100x5' %></p>
  </div>
  <div class="form-item">
    <p><strong><%= f.label :csv_test_data, 'Choose Test Data CSV' %></strong></p>
    <p><%= f.file_field :csv_test_data, accept: '.csv' %></p>
  </div>
  <div class="form-item">
    <p><%= f.submit 'Generate Matrix Chart', class: 'btn btn-primary' %></p>
  </div>
<% end %

マトリックス図は1行ずつ描画される。
それぞれの確信度は ConverseApiHelper#evaluate_confidence ヘルパーメソッドが評価し、その値に応じて色分けを行う。

app/views/converse_api/generate_matrix.html.erb

<h1>Matrix Chart of Confidence</h1>
<p><%= link_to 'Export CSV', converse_api_export_matrix_path, method: :post, class: 'btn btn-primary' %></p>
<div class="scroll-table">
  <table>
    <% if @matrix %>
      <% @matrix.each do |matrix| %>
        <tr>
          <% matrix.each do |row| %>
            <th class=<%= evaluate_confidence(row) %>><%= row %></th>
          <% end %>
        </tr>
      <% end %>
    <% end %>
  </table>
</div>

<div class="legend">
  <h2>Legend</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>

  • Greater than or Equal to 70.0%: .excellent
  • Greater than or Equal to 50.0% and less than 70.0%: .good
  • Greater than or Equal to 30.0% and less than 50.0%: .bad
  • Greater than or Equal to 0.1% and less than 30.0%: .useless

app/helpers/converse_api_helper.rb

module ConverseApiHelper
  def evaluate_confidence(row)
    return unless row.include?('%')
    if row.to_f == 0.0
      ''
    elsif row.to_f >= 70.0
      'excellent'
    elsif row.to_f >= 50.0
      'good'
    elsif row.to_f >= 30.0
      'bad'
    else
      'useless'
    end
  end
end

app/assets/stylesheets/application.css

.excellent {
  background-color: #1971ff;
  color: #fff;
}

.good {
  background-color: #00b06b;
}

.bad {
  background-color: #f2e700;
}

.useless {
  background-color: #ff4b00;
  color: #fff;
}

.legend {
  max-width: 40%;
  margin-top: 30px;
  padding: 15px;
  background-color: #ededed;
}

.legend h2 {
  margin-top: 0;
  margin-bottom: 10px;
}

.legend p {
  margin: 0;
  padding: 5px;
}

6. E2Eテスト

System Specを利用するには、以下の設定を定義する。

spec/rails_helper.rb

# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'

require File.expand_path('../config/environment', __dir__)

RSpec.configure do |config|
  config.include DownloadHelper, type: :system, js: true
  config.before(:suite) { Dir.mkdir(DownloadHelper::PATH) unless Dir.exist?(DownloadHelper::PATH) }
  config.after(:example, type: :system, js: true) { clear_downloads }
...
  config.before(:each) do |example|
    if example.metadata[:type] == :system
      driven_by(:selenium, using: :headless_chrome, screen_size: [1400, 1080]) do |option|
        option.add_argument('no-sandbox')
        option.add_argument('--lang=ja-jp')
      end

      page.driver.browser.download_path = DownloadHelper::PATH
    end
  end
end

このテストヘルパーはファイルダウンロードをシミュレートするメソッドを提供する。

spec/support/download.rb

module DownloadHelper
  TIMEOUT = 10
  PATH    = Rails.root.join("tmp/downloads")

  extend self

  def downloads
    Dir[PATH.join("*")]
  end

  def download
    downloads.last
  end

  def download_content
    wait_for_download
    File.read(download)
  end

  def wait_for_download
    Timeout.timeout(TIMEOUT) do
      sleep 0.1 until downloaded?
    end
  end

  def downloaded?
    !downloading? && downloads.any?
  end

  def downloading?
    downloads.grep(/\.crdownload$/).any?
  end

  def clear_downloads
    FileUtils.rm_f(downloads)
  end

  def download_file_name
    wait_for_download
    File.basename(download)
  end
end

spec/system/json_converters_spec.rb

require 'rails_helper'

RSpec.describe "ConverseApi", type: :system do
  before do
    visit json_converters_select_csv_path
  end

  describe 'Convert learning CSV data to JSON data' do
    it 'enables users to get to json_converters_select_csv_path' do
      expect(page).to have_current_path json_converters_select_csv_path
    end

    context 'CSV file is chosen' do
      it 'succeeds in converting CSV file to JSON file' do
        attach_file 'Choose CSV Learning Data', "#{Rails.root}/spec/factories/learning_data.csv"
        click_on 'Export JSON'
        expect(download_file_name).to match(/learning_data.*json/)
      end
    end

    context 'CSV file is NOT chosen' do
      it 'shows error message' do
        click_on 'Export JSON'
        expect(page).to have_selector '.alert-danger', text: 'Choose a learning data csv.'
      end
    end
  end
end

spec/system/converse_api_spec.rb

require 'rails_helper'

RSpec.describe "ConverseApi", type: :system do
  before do
    visit converse_api_select_data_path
  end

  describe 'Render HTML matrix chart and doanload CSV' do
    it 'enables users to get to converse_api_select_data_path' do
      expect(page).to have_current_path converse_api_select_data_path
    end

    context 'Form items are filled with proper values' do
      before do
        select 'https', from: 'protocol'
        fill_in 'Host', with: ENV['BOTPRESS_HOST']
        fill_in 'Bot ID', with: ENV['BOTPRESS_BOT_ID']
        fill_in 'User ID', with: ENV['BOTPRESS_USER_ID']
        fill_in 'Bearer Token', with: ENV['BOTPRESS_BEARER']
        attach_file 'Choose Test Data CSV', "#{Rails.root}/spec/factories/test_data.csv"
        click_on 'Generate Matrix Chart'
      end

      it 'succeeds in rendering HTML matrix chart' do
        expect(page).to have_current_path converse_api_generate_matrix_path
      end

      it 'succeeds in downloading CSV matrix chart' do
        click_on 'Export CSV'
        expect(download_file_name).to match(/matrix.*csv/)
      end

      it 'returns to root_path when page is reloaded' do
        visit current_path
        expect(page).to have_current_path converse_api_select_data_path
      end
    end

    context 'Form items are filled with random Host' do
      before do
        select 'https', from: 'protocol'
        fill_in 'Host', with: 'foo'
        fill_in 'Bot ID', with: ENV['BOTPRESS_BOT_ID']
        fill_in 'User ID', with: ENV['BOTPRESS_USER_ID']
        fill_in 'Bearer Token', with: ENV['BOTPRESS_BEARER']
        attach_file 'Choose Test Data CSV', "#{Rails.root}/spec/factories/test_data.csv"
        click_on 'Generate Matrix Chart'
      end

      it 'fails in rendering HTML matrix chart and an error message is shown' do
        expect(page).to have_current_path converse_api_generate_matrix_path
        expect(page).to have_selector '.alert-danger', text: 'It failed to successfully create a matrix chart. Input correct Host.'
      end
    end

    context 'Form items are filled with random BotID, UserID and Bearer Token' do
      before do
        select 'https', from: 'protocol'
        fill_in 'Host', with: ENV['BOTPRESS_HOST']
        fill_in 'Bot ID', with: 'foo'
        fill_in 'User ID', with: 'bar'
        fill_in 'Bearer Token', with: 'piyo'
        attach_file 'Choose Test Data CSV', "#{Rails.root}/spec/factories/test_data.csv"
        click_on 'Generate Matrix Chart'
      end

      it 'fails in rendering HTML matrix chart and an error message is shown' do
        expect(page).to have_current_path converse_api_generate_matrix_path
        expect(page).to have_selector '.alert-danger', text: 'It failed to successfully create a matrix chart. Input correct Bot ID, User ID and Bearer Token.'
      end
    end

    context 'CSV file is NOT chosen' do
      it 'succeeds in converting CSV file to JSON file' do
        click_on 'Generate Matrix Chart'
        expect(page).to have_selector '.alert-danger', text: 'Fill in values or choose files in each field.'
      end
    end
  end
end

7. まとめ

"VC"モデルでアプリケーションを実装するにはRailsはリッチすぎるし、提供される様々な便利な機能を削って薄くしていく作業が馬鹿馬鹿しく感じる部分があった。
また、モデル層なしのコード実装は変な難しさがあり、奇妙な経験だった。

この実装には豪も満足していないし、「はじめに」で言及したように別の軽量フレームワークに代替したいと思っている。

8. 成果物