自社サービスの機能を把握できていない問題
自社でサービスを提供していても、その機能を把握できていないというのはよくあることです。
私も自社サービスの機能をほとんど把握できていません・・・。
部署が違うとか、自分の担当範囲が違うとか、そういう理由があるかもしれませんが、そのような状況を解消するための方法を考えてみました。
自社サービスについてのクイズを出すSlack Appの作成
自社サービスについてのクイズを出すSlack Appを作成することで、自社サービスの機能を把握することができるかも・・・と考えました。
クイズを作るのはAIにまかせれば作れそう。
クイズの情報源とするのは、自社サービスのマニュアル。
マニュアル情報を元にクイズを作るのでRAGを利用すれば良さそうですね。
LangChainとChromaDBを使うのでPythonで作成することにします。
構成
RAGでChromaDBを使うため、そこそこのスペックが必要です。
ただあまりリソースは使いたくないので、RAGを動かす部分だけスペックが良いVMで実行、Slack App自体はGoogle Apps Scriptで実装することにしました。
自社サービスのマニュアルの情報を取得する
マニュアルはHTMLページでインターネット上に公開しているので、スクレイピングして情報を取得すれば良いかなと考えました。
注意
今回は自社サイトのマニュアルをスクレイピングしており、社内許可は得ています
スクレイピングする場合は、そのサイトの利用規約を確認し、遵守してください
CloudFlare DDoS Protection の壁
スクレイピング対象のサイトに CloudFlare DDoS Protection が設定されていると、スクレイピングが難しくなります。
マニュアルのページもCloudFlare DDoS Protection が設定されているので、requests
などのライブラリで単純に取得しようとすると、403 Forbidden
が返ってきます。
これを回避するために、pyppeteer
を使ってスクレイピングすることにしました。
が、pyppeteer
は pyppeteer-stealth
を使っても CloudFlare DDoS Protection を回避できないようです。
これはpupeeter
の問題ではなくChromiumの仕様のようです。
なので、pyppeteer
の利用をやめて playwright
で、Firefox を利用することで突破しました。
playwright
+ Firefox のみで CloudFlare DDoS Protection を突破できたのですが、念の為 playwright-stealth
も入れておきます。
マニュアルのクロール
マニュアルのインデックスページから同一ドメインのリンクをたどり、マニュアルを収集することにしました。
ただ、マニュアルのページは非同期のスクリプトによるレンダリングもあり、 wait_until="load"
では完全なコンテンツが取得できませんでした。
このため
await page.goto(url, wait_until="networkidle", timeout=10000)
のように networkidle
オプションを指定して、ネットワークのアイドル状態を待つようにしました。
これでコンテンツが取得できるようになったのですが、今度はマニュアルの情報には不要のGoogle Analytics等のスクリプト等の処理も待ってしまうようになり、ページの読込が遅くなってしまいました。
これを解消するために、playwright
の route
を使って、不要なリクエストをブロックするようにしました。
await page.route(
"**/*",
lambda route: (
route.abort()
if route.request.resource_type in ["image", "stylesheet"]
or "www.google-analytics.com" in route.request.url
or "www.googletagmanager.com" in route.request.url
else route.continue_()
),
)
不要なリクエストをブロックしたことで、ページの読込が改善されました。
あとは、ページ内のリンクを辿り、マニュアルの情報を取得するだけです。
取得したデータは以下のように格納しました。
page_info["ページURL"] = {
"title": "ページタイトル",
"content": "ページコンテンツを markdownify で変換したもの",
"breadcrumbs": "ページまでのパンくずリスト",
}
breadcrumbsは無くてもいいです。
あればAIがページ間の関連を読み取ってくれるかな・・・と思い入れているだけです。
ページURLは、クイズの解答の情報源として解答と一緒にPostしたいので、その情報を格納しています。
ベクターデータに変換
クロールしたマニュアルデータをRAGで使用できるようにするため、ベクターデータに変換していきます。
Embeddingに使用するモデルは oshizo/sbert-jsnli-luke-japanese-base-lite
を使用しました。
もっといいモデルがあれば教えてください。
RAG によるクイズ生成
クロールしたマニュアルデータからランダムにページタイトルを取得し、ページタイトルを元にクイズを生成するようにしました。
また、クイズの難易度もばらつきがあったほうが面白いと思い、難易度もランダムに設定するようにしました。
DIFFICULTY = ["超上級", "上級", "中級", "初級"]
difficulty = random.choice(DIFFICULTY)
また生成されるクイズの形式は、JSONのような決まった形式で受け取りたかったので、LangChainの StructuredOutputParser
を使ってみました。
これで指定した構造でデータを受け取ることができるようになります。
以下のようにクイズデータのスキーマを定義しました。
quiz_response_schemas = [
ResponseSchema(name="question", description="クイズの問題文"),
ResponseSchema(
name="choices", description="クイズの選択肢(4つの値のリスト)", type="string[4]"
),
ResponseSchema(
name="answer", description="クイズの答えの選択肢のインデックス", type="int"
),
ResponseSchema(name="explanation", description="クイズの解説"),
ResponseSchema(name="hint", description="クイズのヒント"),
ResponseSchema(
name="source_urls", description="クイズの出典元のURLのリスト", type="string[]"
),
]
これを用いて応答をParseすることで定義したスキーマに従ってクイズデータを受け取ることができました。
Slack App の作成
GASでは以下の実装を行いました。
- 生成したクイズデータをdoPostで受け取り、スクリプトプロパティに格納
- 毎日業務開始時刻にクイズをSlackに投稿
- 参加者数のリアルタイム(に近い)表示
- 毎日指定時刻にクイズの解答をSlackに投稿
- Slash commandによるヒントの提供
- 営業日のみ出題(祝日以外の会社の休みも自動判定)
毎日業務開始時刻にクイズをSlackに投稿
クイズに対して、どのようにユーザから回答を得るのか考え、絵文字で 1️⃣ 2️⃣ 3️⃣ 4️⃣ を使って、選択肢を表示し、ユーザが該当の絵文字を使いリアクションで選択するというのも良いのですが、もっといい方法がないかSlackのドキュメントを調べてみると Interactive messages というものがありました。
これを使うと、ボタンやモーダルを表示してユーザから回答を受け取ることもできそうと考え採用しました。
また、Interactive messagesで表示するためのJSONは、Block Kit Builder というツールを使うと簡単に作成できました。
こんな感じで投稿されます
回答ボタンをクリックするとモーダルが表示されます
回答後も締め切り時間までは回答を更新できます
締め切り時間を過ぎて回答ボタンがクリックされた場合も考慮し、締切済みのモーダルを表示するようにしました
参加者のリアルタイム(に近い)表示
どうせならクイズに回答してもらった人数を表示したいと思い、chat.update
にて参加人数を更新できるようにしました。
クイズ出題時に、1分ごとに参加者数を更新するトリガーを作り、解答がPostされるまでの間、参加者が更新されるようにしました。
また解答のPost時に、参加者数を更新するトリガーを削除するようにしました。
回答者が増えると更新されます
毎日指定時刻にクイズの解答をSlackに投稿
解答は出題から30分後にPostするようにしました。
あまり時間を空けると興味を失ってしまうかなと思い、いったん30分としています。
解答と併せて、解説、マニュアルサイトのURL、正解者、参加回数でのキリ番ゲットを表示するようにしました。
また、みんなが何に投票したかを確認するために、■を使ってなんちゃって棒グラフで表示するようにしました。
キリ番は、連続回答数や、正解数でカウントすると気軽に参加できないかなと思い、あえて参加回数でカウントするようにしました。
Slash commandによるヒントの提供
Slash commandを使って、ヒントを提供するようにしました。
また、ヒントを提供する際には、response_type: 'ephemeral'
でヒントを返し、他のユーザには見えないようにしました。
『うゎあいつヒント見てる!』等気にすることなく、気軽にヒントを見ることができるようにしました。
if (json.command == '/quiz_hint') {
return ContentService.createTextOutput(JSON.stringify({
response_type: 'ephemeral',
text: `ヒント: ${quizData.hint}`
})).setMimeType(ContentService.MimeType.JSON);
}
/quiz_hint
と打つと、あなただけに表示されています と表示され、他の人には見えないようにこっそりヒントを教えてくれます。
営業日のみ出題 (祝日以外の会社の休みも自動判定)
祝日だけの判定であれば、Googleカレンダーで日本の祝日のカレンダーを参照すればよいのですが、GWや夏季休暇、年末年始休暇等に会社独自の休暇が設定されている場合はそうも行きません。
会社ではGoogle Workspaceを使っているのですが、カレンダーは他のアプリを使っているためGoogleカレンダーで休み等が管理されていません。
また、会社で使用しているカレンダーアプリは今どき珍しくAPIが提供されていないためプログラムからの休日判定が難しい状態です。
ただ、私の部には出社報告をするSlackチャンネルがあり、そのチャンネルに一定数の出社報告があれば営業日とみなすことができそうなので、「業務開始時間までに5件以上業務開始
という文字列があれば営業日」と判定してクイズを出題するようにしてみました。
有給奨励日等もあるので、件数のしきい値は運用してみて調整が必要かもしれません。
今後の応用
そこそこのものができたので、今後社内で展開できそう
- サービスデスクでのFAQをもとにクイズを作り、サービスデスク側にクイズを出す
- 内部仕様をもとにクイズを作り、開発・仕様チームにクイズを出す
などなど。
作ってみて
- マニュアルのクロールを行うことで、サイト内の不正なリンクを複数発見することができた
サイトのメンテナンスにも直接貢献する結果となり、定期的なクロールによるチェックは非常に有効だと感じた
ただ、いつまで CloudFlare DDoS Protection をすり抜けられるのか不明なので、特定の接続元からは CloudFlare を無効にするなど正式な対応を依頼したほうが良さそう - SlackのInteractive messagesを初めて知った
これを使うことで、よりインタラクティブでユーザー参加型のコンテンツを簡単に作成できることがわかった
今までよりリッチなSlack App開発ができそう
運用してみて
<社内での反応など後日追記します>