自社サービスのマニュアルをAIがクイズ化！社員の知識向上を目指すSlack Appを作った


## 自社サービスの機能を把握できていない問題

自社でサービスを提供していても、その機能を把握できていないというのはよくあることです。
私も自社サービスの機能をほとんど把握できていません・・・。
部署が違うとか、自分の担当範囲が違うとか、そういう理由があるかもしれませんが、そのような状況を解消するための方法を考えてみました。

---
## 自社サービスについてのクイズを出すSlack Appの作成

自社サービスについてのクイズを出すSlack Appを作成することで、自社サービスの機能を把握することができるかも・・・と考えました。
クイズを作るのはAIにまかせれば作れそう。
クイズの情報源とするのは、自社サービスのマニュアル。
マニュアル情報を元にクイズを作るのでRAGを利用すれば良さそうですね。
LangChainとChromaDBを使うのでPythonで作成することにします。

---
## 構成

RAGでChromaDBを使うため、そこそこのスペックが必要です。
ただあまりリソースは使いたくないので、RAGを動かす部分だけスペックが良いVMで実行、Slack App自体はGoogle Apps Scriptで実装することにしました。

![app_quiz.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/4cb0feeb-2df1-2131-7f2b-78defdc32e80.png)

---
## 自社サービスのマニュアルの情報を取得する

マニュアルはHTMLページでインターネット上に公開しているので、スクレイピングして情報を取得すれば良いかなと考えました。

:::note warn
注意
今回は自社サイトのマニュアルをスクレイピングしており、社内許可は得ています
スクレイピングする場合は、そのサイトの利用規約を確認し、遵守してください
:::

---
### CloudFlare DDoS Protection の壁


![ddos_converted.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/19f757c9-88e1-08a1-9eb2-f65c8ef2d537.png)



スクレイピング対象のサイトに 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"` では完全なコンテンツが取得できませんでした。
このため
```python
await page.goto(url, wait_until="networkidle", timeout=10000)
```
のように `networkidle` オプションを指定して、ネットワークのアイドル状態を待つようにしました。
これでコンテンツが取得できるようになったのですが、今度はマニュアルの情報には不要のGoogle Analytics等のスクリプト等の処理も待ってしまうようになり、ページの読込が遅くなってしまいました。
これを解消するために、`playwright` の `route` を使って、不要なリクエストをブロックするようにしました。

https://playwright.dev/python/docs/api/class-page#page-route

```python
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_()
    ),
)
```
不要なリクエストをブロックしたことで、ページの読込が改善されました。
あとは、ページ内のリンクを辿り、マニュアルの情報を取得するだけです。
取得したデータは以下のように格納しました。
```python
page_info["ページURL"] = {
    "title": "ページタイトル",
    "content": "ページコンテンツを markdownify で変換したもの",
    "breadcrumbs": "ページまでのパンくずリスト",
}
```
breadcrumbsは無くてもいいです。
あればAIがページ間の関連を読み取ってくれるかな・・・と思い入れているだけです。
ページURLは、クイズの解答の情報源として解答と一緒にPostしたいので、その情報を格納しています。

---
## ベクターデータに変換

クロールしたマニュアルデータをRAGで使用できるようにするため、ベクターデータに変換していきます。
Embeddingに使用するモデルは `oshizo/sbert-jsnli-luke-japanese-base-lite` を使用しました。
もっといいモデルがあれば教えてください。

---
## RAG によるクイズ生成

クロールしたマニュアルデータからランダムにページタイトルを取得し、ページタイトルを元にクイズを生成するようにしました。
また、クイズの難易度もばらつきがあったほうが面白いと思い、難易度もランダムに設定するようにしました。
```python
DIFFICULTY = ["超上級", "上級", "中級", "初級"]

difficulty = random.choice(DIFFICULTY)
```

また生成されるクイズの形式は、JSONのような決まった形式で受け取りたかったので、LangChainの `StructuredOutputParser` を使ってみました。
これで指定した構造でデータを受け取ることができるようになります。

https://python.langchain.com/docs/modules/model_io/output_parsers/types/structured/

以下のようにクイズデータのスキーマを定義しました。
```python
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** というツールを使うと簡単に作成できました。

https://app.slack.com/block-kit-builder/

こんな感じで投稿されます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/e99a81e5-5538-4312-a67f-84e9333baa02.png)

回答ボタンをクリックするとモーダルが表示されます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/27e744fe-0678-2e82-fd2f-180d968b3cee.png)

回答後も締め切り時間までは回答を更新できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/43fca462-c079-ab4f-e270-85f946137648.png)

締め切り時間を過ぎて回答ボタンがクリックされた場合も考慮し、締切済みのモーダルを表示するようにしました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/eb53513b-7242-7fd1-3709-9d4092567d01.png)

---
### 参加者のリアルタイム(に近い)表示

どうせならクイズに回答してもらった人数を表示したいと思い、`chat.update` にて参加人数を更新できるようにしました。
クイズ出題時に、1分ごとに参加者数を更新するトリガーを作り、解答がPostされるまでの間、参加者が更新されるようにしました。
また解答のPost時に、参加者数を更新するトリガーを削除するようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/d7eaedfa-eed9-2532-271c-a08c1e440f99.png)

回答者が増えると更新されます

![スクリーンショット 2024-05-08 10.04.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/6a4af239-e41e-93fc-5350-f1128b549ca6.png)

---
### 毎日指定時刻にクイズの解答をSlackに投稿

解答は出題から30分後にPostするようにしました。
あまり時間を空けると興味を失ってしまうかなと思い、いったん30分としています。

解答と併せて、解説、マニュアルサイトのURL、正解者、参加回数でのキリ番ゲットを表示するようにしました。
また、みんなが何に投票したかを確認するために、■を使ってなんちゃって棒グラフで表示するようにしました。
キリ番は、連続回答数や、正解数でカウントすると気軽に参加できないかなと思い、あえて参加回数でカウントするようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/f6eb0b67-8aa2-e622-8d10-e1128ea4246e.png)

---
### Slash commandによるヒントの提供

Slash commandを使って、ヒントを提供するようにしました。
また、ヒントを提供する際には、`response_type: 'ephemeral'` でヒントを返し、他のユーザには見えないようにしました。
『うゎあいつヒント見てる！』等気にすることなく、気軽にヒントを見ることができるようにしました。

```ts
if (json.command == '/quiz_hint') {
  return ContentService.createTextOutput(JSON.stringify({
    response_type: 'ephemeral',
    text: `ヒント: ${quizData.hint}`
  })).setMimeType(ContentService.MimeType.JSON);
}
```

`/quiz_hint` と打つと、**あなただけに表示されています** と表示され、他の人には見えないようにこっそりヒントを教えてくれます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3741957/238ef229-772c-b554-9c47-a4494ec77c81.png)

---
### 営業日のみ出題 (祝日以外の会社の休みも自動判定)

祝日だけの判定であれば、Googleカレンダーで**日本の祝日**のカレンダーを参照すればよいのですが、GWや夏季休暇、年末年始休暇等に会社独自の休暇が設定されている場合はそうも行きません。
会社ではGoogle Workspaceを使っているのですが、カレンダーは他のアプリを使っているためGoogleカレンダーで休み等が管理されていません。
また、会社で使用しているカレンダーアプリは今どき珍しくAPIが提供されていないためプログラムからの休日判定が難しい状態です。
ただ、私の部には出社報告をするSlackチャンネルがあり、そのチャンネルに一定数の出社報告があれば営業日とみなすことができそうなので、「業務開始時間までに5件以上`業務開始`という文字列があれば営業日」と判定してクイズを出題するようにしてみました。
有給奨励日等もあるので、件数のしきい値は運用してみて調整が必要かもしれません。

---
## 今後の応用

そこそこのものができたので、今後社内で展開できそう

* サービスデスクでのFAQをもとにクイズを作り、サービスデスク側にクイズを出す
* 内部仕様をもとにクイズを作り、開発・仕様チームにクイズを出す

などなど。

---
## 作ってみて

* マニュアルのクロールを行うことで、サイト内の不正なリンクを複数発見することができた
  サイトのメンテナンスにも直接貢献する結果となり、定期的なクロールによるチェックは非常に有効だと感じた
  ただ、いつまで CloudFlare DDoS Protection をすり抜けられるのか不明なので、特定の接続元からは CloudFlare を無効にするなど正式な対応を依頼したほうが良さそう
* SlackのInteractive messagesを初めて知った
  これを使うことで、よりインタラクティブでユーザー参加型のコンテンツを簡単に作成できることがわかった
  今までよりリッチなSlack App開発ができそう

## 運用してみて
<社内での反応など後日追記します>