🤖

小さなスタートアップが社内でちまちま育てているチャットボット

2023/10/23に公開

こんにちは。FUSSY でエンジニアをしているfunwarioisiiです。
最近 Young Sheldon を U-NEXT で見終えました。続編の公開が待ち遠しいです。

さて、今回は FUSSY が社内で使っているチャットボットについて紹介します。
自分がまだ大学生になったころ、 2013年頃、巷ではチャットボットが流行っていた気がします。
hubot とか、 ruboty とか bolt(これは新しいか)とかいろいろありましたね。

https://github.com/hubotio/hubot

https://github.com/r7kamura/ruboty

https://github.com/SlackAPI/bolt-python

現在は導入が進み、コミュニケーションの円滑化や、業務の効率化に貢献しているのではないでしょうか。
2023年のスタートアップでどのように ChatBot を使っているか、紹介し、似た境遇の方に参考にしていただければと思います。
紹介するコマンド自体はわかりやすい機能ばかりです。
なのでその話をしても仕方ないなという気持ちになったので、これが必要になった背景なども紹介します。

FUSSY とは

現在 FUSSY は「推しの保存と布教」のためのサービスを開発しています。
私含め3人で運営しているスタートアップで、エンジニアは私1人です。
会社概要はこちらをご覧ください。

https://fussy-inc.co.jp

そんな状況で、コミュニケーションツールには Discord を使っています。
主たる理由としては、他サービスに比べて料金面で優れていることでした。

議事録や Issue などのストック情報は Notion にまとめています。
まずはこの、 Notion の活用と Discord との連携について紹介します。

!issue コマンド

Notion に Issue を作成するコマンドです。

(画像)

FUSSY はまだまだ伸びしろばかりのスタートアップです。
眼の前の課題、大きな課題、課題しかないです。

どんな小さな課題でもどう解くかをチームで認識を揃えることで、他の仕事が進めやすくなったり、小さく見えていた課題の裏に潜む、大きな課題に気づくことができます。
そのため、 FUSSY では毎週月曜日に、チームで課題をどう解決したかやどういう課題を見つけたかを共有するミーティング、 Issue会をしています。
この Issue の起票について、 Notion に直接起票してもいいのですが、それだと月曜日まで課題に気づきにくいという問題があります。せっかく Discord を使ってるのにもったいないです!
Issue会で初めて Issue に気づくより、先にそういった Issue があることを認識できているほうがより良い時間になると考えています。リアルタイムに悩みが見える方がいいです。

関連した話題として、GitHub での Issue 起票時に Slack に通知される機能が好きです。
Notion はGitHubと違い「公開する」ステップがないので、 Slack で通知を受け取っていた際は、タイミングがドキュメントの更新ごとで書きかけの情報が流れてきてノイジーというのもありました。

https://note.com/mrtn/n/n6772194b0d74

を参考に作ったこともありましたが、Issue に関しては Discord から起票するほうが良いコミュニケーションになると考えて運用しています。

さて、話を戻して、 !issue コマンドの実装です。
FUSSY では Issue をデータベースの中のページとして管理しています。
タグ付けによる運用が簡単になり、リスト表示や進捗でのカンバンのような使い方ができるからです。
この運用に乗せるために、 Notion の API を使って Issue を作成しています。

notion-ruby-client という gem を使っています。
https://github.com/orbit-love/notion-ruby-client

require 'notion-ruby-client'

# create issue on notion
def create_issue(title:)
  client = Notion::Client.new(token: ENV['NOTION_API_TOKEN'])
  database_id = ENV['NOTION_DATABASE_ID']

  properties = {
    Name: {
      title: [
       {
        text: {
          content: title
        }
       }
      ]
    },
    Tags: {
      multi_select: [
        {
        name: 'Issue'
        }
      ]
    },
    Status: {
      select: {
        name: 'Not Started'
      }
    }
  }

  client.create_page(
    parent: { database_id: },
    properties:
  )
end

title = 'なんかいい感じにならない'

result = create_issue(title:)

"check it! #{result.url}"

!summary コマンド

みんな大好き LLM です。
FUSSY ではチーム内でのコンテキストの共有を大事にしています。
そのため、どんなニュースを読んだかなどを共有したりするのですが、記事が長いと読まれにくいです。

そこで、 !summary <URL> というコマンドを作りました。
リンク先を読みに行って、 LLM に要約させています。

コードはこんな感じです。

require 'open-uri'
require 'nokogiri'
require 'net/http'

OPENAI_API_KEY = ENV['OPENAI_API_KEY']

def summary(args, _meta)
  url = args.first
  simple_text = fetch_simple_text(url)
  response = Net::HTTP.post(
    URI('https://api.openai.com/v1/chat/completions'),
    {
      "model": 'gpt-3.5-turbo-16k',
      "messages": [
        { "role": 'system', "content": '次の文章を要約してください。返答は日本語でお願いします。返答は「要約しました。」からはじめてください。' },
        { "role": 'user', "content": simple_text }
      ]
    }.to_json,
    'Content-Type' => 'application/json',
    'Authorization' => "Bearer #{OPENAI_API_KEY}"
  )
  response_body = JSON.parse(response.body)
  response_body['choices'].first['message']['content']
end

def fetch_simple_text(url)
  html = Net::HTTP.get(URI(url))
  doc = Nokogiri::HTML.parse(html)
  elements = doc.search('//p|//h1|//h2|//h3|//h4|//h5|//h6|//article|//section|//span')
  text = elements.map { |element| element.text.strip }.join(' ')
  text.gsub(/\s+/, ' ').strip
end

summary('https://google.com')

非常に情けないのですが、gpt-3.5-turbo-16k で扱えるトークン数を超える場合はエラーになります。
長すぎると要約できないので、改善したいなと思っています。
また、 GPT-4 系を選択すると、時間がかかりすぎて体験が悪かったため選択していません。

!revalidate コマンド

ここはちょっとスタートアップっぽい話です。サービス全体においてまだコンテンツ数が少ないため、温かみのある手オペをしています。

FUSSY はサーバサイドをRailsで、フロントエンドをNext.jsで書いています。
そして Next.js は Vercel でデプロイしています。
SSR に時間がかかるなどの色々な問題で、SSG+ISR でコンテンツを管理しています。

ここで問題になったのが修正したデータの反映です。
あるとうれしいのは管理画面でデータを修正したら、そのデータがすぐに反映されることです。

少ない個別対応のために管理画面を作るのはコスパが悪そうです。
現状は直接データベースにアクセスして修正しています。
しかし、それでは SSG の成果物に変更が反映されないので、 !revalidate というコマンドを作り Discord から revalidate できるようにしました。
revalidate に成功するとそのURLが返ってくるようになっているので、開発メンバー以外もリアルタイムに変更作業が見られて安心できます。

対応する量が増えたら管理画面を作るかもしれません。

!ruby コマンド

ボツになったコマンドです。
https://zenn.dev/funwarioisii/scraps/4c6d429e63f901 に書いたので、そちらをご覧ください。

理由はいくつかあり

  • 使わない
  • 必要とするメモリが多く、EC2インスタンスサイズを上げないと動かない

といったところです。
掃除当番決めるのに %w[watasi omae].sample などは便利なのですが、それなら個別に !omikuji などを作ると良さそうです。

!bot コマンド

Slack でよくある、 bot が反応するコマンドです。
https://nadeko.bot がこの辺をしっかりやっていそうです。

FUSSY でも同等のものを実装していて、!bot register こんにちは hello というコマンドを実行すると、 !bot こんにちは というメッセージに対して hello と返してくれます。
現状は !bot を先につける必要があり、やや不便なので、登録されているワードが来たら自動で返すようにしたいです。

チームで共有している Google Drive の位置や会社の住所などを登録しています。
割と使われない機能の一つです。

require 'sqlite3'

DATABASE_NAME = 'data/bot.sqlite'

def register(service_id, wake_word, word)
  db = SQLite3::Database.new(DATABASE_NAME)
  db.execute('INSERT INTO wake_word_responses (server_id, wake_word, response) VALUES (?, ?, ?)',
             [service_id, wake_word, word])
  <<~TEXT
    #{wake_word}#{word} を登録しました
  TEXT
ensure
  db.close
  'エラーが発生しました。もう一度やり直してください。'
end
def respond(server_id, wake_word)
  db = SQLite3::Database.new(DATABASE_NAME)
  begin
    responses = db.execute('SELECT response FROM wake_word_responses WHERE server_id = ? AND wake_word = ?',
                           [server_id, wake_word])
    responses.sample&.first || "「#{wake_word}」 に対する応答が見つかりませんでした"
  rescue StandardError
    db.close
  end
end
def list(server_id)
  db = SQLite3::Database.new(DATABASE_NAME)
  begin
    responses = db.execute('SELECT wake_word, response FROM wake_word_responses WHERE server_id = ?', [server_id])
    responses.map { |wake_word, response| "#{wake_word} => #{response}" }.join("\n")
  rescue StandardError
    db.close
  end
end

一応別のサーバーでも使えるように server_id でわけています。

fixupx ハンドラー

ここまではコマンドの紹介でしたが、最後にハンドラーについて紹介します。
良い命名が思いつかなかったので、ハンドラーと呼んでいますが、送られたメッセージ全て(コマンド以外)に対してなんらかの処理を試みる仕組みです。
先ほど紹介した !bot が自動で反応しない不便を解消するために、コマンドではなく、ハンドラーの仕組みに乗せたいと考えています。

ここで紹介するのは実装済みの fixup ハンドラーです。
Discord では x.com や twitter.com のOGPがうまく展開されません。
https://github.com/FixTweet/FixTweet の実装が使われている fxtwitter.com というサービスを使っています

たとえば、 Discord で https://twitter.com/FUSSY_OFFICIAL/status/1713901972150157417 はOGPが展開されませんが、 https://fxtwitter.com/FUSSY_OFFICIAL/status/1713901972150157417 とすれば表示されます。

Discord でのコミュニケーションで Twitter の URL が貼られることが多いので、これを変換し、展開されるようにしています。

def replace(message)
  unless message.match?(%r{https?://twitter\.com/.*?/status/}) || message.match?(%r{https?://x\.com/.*?/status/})
    return nil
  end
  message.gsub(%r{https?://(x|twitter)\.com/}, 'https://fxtwitter.com/')
end

まとめ

FUSSY のチャットボットでできることやその背景について紹介しました。
片手間で機能を追加し、憂さ晴らしできて、メンテコストも低い。やっぱりチャットボットは最高です。
語り尽くされていそうな話題ですが、2023年っぽい LLM や fixupx などの話もできたのではないかと思います。
読んでくださった方の最近のチャットボット事情を共有いただけると幸いです。

最後に少々宣伝を。
チャットボットではなく、サービスの方は現在事前登録受付中です。
https://fussy-inc.co.jp/pre-registration
お時間あればご覧ください。

Discussion