0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

(アイデア)ステートフルなチャットボットの実装について

Last updated at Posted at 2023-10-15

概要

  • LINEをはじめとするプラットフォーム上で実現するBotは、実体としてはREST APIを提供するサーバーである性質上、一度のやりとりで都度処理が完結する。
  • 一方で、実用的なBotを実現するには、状態を保持したうえで複数回のやり取りする必要がある。
  • ステートフルなBotを実現するために、一般的なコマンドラインプログラムの実装をお手本に、見通しの良い実装を試みる。

単純なチャットボットを考える。

LINEをはじめとするメッセージングプラットフォームにおけるBotは、Webアプリケーションやスマートデバイスのアプリと異なり、画面を開発する必要がないため、機能を実現するための実装の簡略化や工数削減に役に立ちます。
一方、その機能を実現するための構成そのものは、画面を持つアプリケーションと同様にバックエンドのサーバーが存在し、実体としてはRESTインターフェースを持つアプリケーションとして動いています。

プラットフォーム間の違いには目をつぶるとして、構成としては概ね下記のようなものと思われます。

わかりやすいようにメッセージングプラットフォームをLINEという表記に置き換えると、

  1. ユーザーがLINE(Botアカウント)にメッセージを送ります。
  2. LINEは、BotサーバーにPOSTリクエストを送ります(Webhookと呼ばれます)。このPOSTリクエストには、メッセージの内容のほか、送信したユーザーのID等が含まれます。
  3. メッセージ内容を受けたBotサーバーは、あらかじめ定義された何らかの処理を行います。
  4. 処理の結果作成されたメッセージを、LINEのメッセージ送信APIを使用してLINEに送ります。
  5. LINEは、Botサーバーに代行してユーザーにメッセージを届けます。

もっとも単純なBotサーバーの実装を考えるとすると、Webhookを受けたときの処理をエンドポイントにて実行し、その後メッセージへの返答内容を含むAPIリクエストを実行するような実装が考えられます。

// Expressでの実装例
router.post('/webhook', (req, res) => {
    val userId = findUserId(req)
    val message = findMessage(req)

    val result = // ~何らかの処理~

    sendMessage(userId,result)

    res.status(200)
    res.end()
}

このとき注意したいのが、Botサーバーの処理を単純なRESTインターフェースの実装のようにリクエストを起点とした実装をすると、ここまでの一連の流れ(ユーザーからのメッセージ→返答)で処理が完結してしまうということです。
RESTインターフェースにおいては、サーバー側は状態を持たないため、ユーザーとBotのやりとりを2回3回と繰り返す処理をすることができません。ユーザーからのメッセージ→返答、という「処理の単位」の制約があるため、一往復の対話をまたぐような処理を書くことはできません。

一方で、チャットボットとしての理想的な姿を、あたかも人間と会話をするようにBotと会話をし、処理を完結することだとすると、たった一言ずつで処理が完結してしまうBotでは十分ではなく、ユーザーとBotが複数回の対話をしたあと、最後にそれらのやり取りを含む処理を行うことができるような工夫が必要になると考えられます。

ステートフルなチャットボットの例

例として、美容院でのユーザーの体験をアンケートで調査するBotの仕様を考えます。

来店したユーザーの情報をもとに、
・「アンケートに協力していただけますか?」
という意味のメッセージを送ります。

「はい」と返答したユーザーに対して、
・「お店の清潔感はいかがでしたか?」
・「担当者の提案に満足できましたか?」
・「仕上がりには満足しましたか?」
・「このお店を他の人にも薦めたいですか?」
等の質問をします。

それぞれについて、「いいえ」の返答を受けた場合は、
・「具体的にどのような部分が不満でしたか?」
というような質問を追加でします。

一連の質問が完了したら、
・「ご回答ありがとうございました。」
とメッセージを送ります

最後に、アンケートの回答結果と、ユーザーの訪問日時と組み合わせてトランザクションデータとして永続化します。

ユーザーに送信する次の質問を導き出すには、アンケートの進捗状況の管理が必要になりますし、最後にデータを永続化するにはそれまでユーザーの回答内容の保持する必要があります。

実際にこのようなBotを作成することを考えると、エンドポイントで処理を実行し返答をするような実装では間に合わず、そうするにしても複雑怪奇なコードになってしまうことがことが想像できると思います。

着想:コマンドラインプログラム当てはめて考える

ところで、上記のBotのふるまいを、単純なコマンドラインプログラムとして実装することを考えます。
プログラミングを学び始めたころ、じゃんけんゲームを作成したことはありますでしょうか。今度はその要領で上記のBotを実装してみます。
コマンドラインプログラムであれば、例えばJavaではScannerクラス、node.jsではreadLineを使用してユーザーに入力を求めることができます。

下記のコードは、Kotlinで実装した場合のソースコードの例です。

// Kotlinでの実装例

sealed interface AnswerItem {}
class Yes() : AnswerItem
data class No(val comment: String) : AnswerItem

data class Answers(
    val cleanliness: AnswerItem,
    val suggestion: AnswerItem,
    val finish: AnswerItem,
    val recommend: AnswerItem,
    val userId: String
) {}

fun doSurvey(val userId) {
    println("本日はご来店ありがとうございました。アンケートにご協力ください。ご協力いただけますか?(はい/いいえ)")
    val answer = readLine()
    if (answer != "はい") {
        println("かしこまりました。またの機会にお願いします。")
        return
    }

    println("お店の清潔感はいかがでしたか?")
    val cleanliness: AnswerItem = if (readLine() == "はい") {
        Yes()
    } else {
        println("どのような点がご不満でしたか?")
        val comment: String = readLine() ?: ""
        No(comment)
    }

    println("担当者の提案に満足できましたか?")
    val suggestion: AnswerItem = if (readLine() == "はい") {
        Yes()
    } else {
        println("どのような点がご不満でしたか?")
        val comment: String = readLine()?: ""
        No(comment)
    }

    //~中略~

    println("ご協力ありがとうございました。")

    val answers = Answers(
        cleanliness = cleanliness,
        suggestion = suggestion,
        finish = finish_,
        recommend = recommend_,
        userId = userId
    )
    repository.saveAnswer(answers)
}

ベストな実装はほかに譲ることにしますが、上記のコードのようにコマンドラインプログラムにおいては上から下に処理が流れる単純な実装が可能です。

さて、コマンドラインプログラムとBotのふるまいは似ています。
どちらもユーザーから入力を受け、その処理結果を出力し、さらにユーザーと対話します。
したがって、Botのソースコードを書く時もコマンドラインプログラムに似た書き方をするアプローチが考えられます。

現状そのようになっていないのは、先に説明した「処理の単位」によるものです。
先のBotサーバーの例では、ユーザーからの入力が処理の起点となり、Botが返答をするまでが一つの処理の塊として実装されています。したがって、一往復の対話をまたぐような処理を書くことができないのでした。

一方でこの例では、一連のアンケートの実施を一つの処理の塊として実装しています。
ユーザーからの入力の受付が処理の途中で挟み込まれており、後続の処理でその入力内容を使うことができています。
こうすることで、一連の処理の見通しが良くなっています。

実装例:処理の途中でユーザーとの対話を挟む

コマンドラインプログラムの形と似せてBotのソースコードを書いてみます。
コマンドラインプログラムの特徴は、処理の途中でユーザーの入力を求めることができる点でした。
同様のことをBotでおこなうために、ここでは仮にJavaScriptのPromiseを使用して考えます。
ユーザーが入力するまで後続の処理を止めておくので、次のような書き心地になります。

// JavaScriptでの実装例

sendMessage(userId,"アンケートにご協力いただけますか?")
let canAnswer = await getMessageBool(userId,"はい","いいえ")
if (!canAnswer) {
    sendMessage(userId,"またの機会にお願いします")
    return;
}

sendMessage(userId,"お店の清潔感はいかがでしたか?")
let cleanliness = await getMessageBool(userId,"よい","わるい")
let cleanlinessComment
if(!cleanliness){
    sendMessage(userId,"どのような点がご不満でしたか?")
    cleanlinessComment = await getMessageText(userId)
}

//~後略~

sendMessageはユーザーにメッセージを送る関数です。console.logやprintlnに該当します。内部ではLINEのメッセージングAPIを実行しているものとしています。
getMessageBoolは、Promiseを返します。インスタンスが作成された時点からユーザーからの応答を待機し、ユーザーからの応答が得られたらその結果を返却するものとしています。(実際には、タイムアウトやtrue/falseの判定など検討事項がありますが、細かい仕様はここでは検討しません)

ここからは、上記の書き心地を実現するためのより下のレイヤーを検討します。
getMessageBoolとgetMessageTextが実際にするべきことは次の通りです。

  • (getMessageBoolの場合)「はい」「いいえ」を送信できるボタンをユーザーに送る
  • Promiseインスタンスを作成し、ユーザーからの返答を受け取ったらresolveされるようにする。
var sessionMap = new Map();
function getMessageText(userId){
    return new Promise((resolve,rej) => {
        sessionMap.set(userId,resolve)
    }
}

getMessageTextでは、Promiseインスタンスを作成し、それを返却しています。
Promise本来の使い方としては、Promiseの引数にセットする関数内で非同期でおこなう処理を記述し、完了したらコールバック関数であるresolveを呼び出すというのが本来の使い方です。
しかしながらここでは、ユーザーからの返答=Webhookの受信ををもって完了するため、Promise内の処理ではresolveを呼び出さず、別の場所で保管しています。
別の場所というのが上のコードで作成したMapです。このMapではユーザーIDとresolve関数を紐づけて管理します。Webhookを受けたときの処理で参照するので、どちらからもアクセスできる場所に配置する必要があります。

最後に、ユーザーからの発話を待ち受ける部分です。

// Express(Node.js)での実装例

router.post('/webhook', (req, res) => {
    val userId = findUserId(req)
    val message = findMessage(req)
    val callback = sessionMap.get(userId)
    if(callback != null){
        callback(message)
    }
    res.status(200)
    res.end()
}

例としてExpressで待ち受ける部分を想定しています。

Webhookを受け取たら、先に保持しておいたマップからユーザーIDからresolve関数を取り出して実行します。
これを実行することで、ビジネスロジックでawaitされていたgetMessageTextにユーザーが返答した本文が戻り、ビジネスロジックが再開されます。

その他考慮すべき事項

この記事に記載の内容のほか、実用に耐えるプログラムとして完成させるためには、下記のような考慮が必要となります。

  • ユーザーが反応しない場合のタイムアウトの設定
    • Promiseが確実にresolveまたはrejectされるようにし、浮いたものが残らないようにする必要があります。
  • Botがユーザーの発言を待機していない状態で、ユーザーが発言したときの処理
    • 上記の実装例においては、ユーザーIDに紐づくresolve関数がMapにないとき、と言い換えることができます。
  • 想定外の返答を受けた場合の処理
    • 具体的には、テキストを求める場面で画像が送付されたり、「はい」「いいえ」を求める場面でそれ以外の文字列が送られてくるような場合の考慮です。

これらの考慮が必要となりますが、本校のテーマとは外れるので実装例等は割愛します。

まとめ

この記事では、コマンドラインプログラムの実装をお手本にBotの実装を考えました。

  • ユーザーの発話とそれへの返答のみで処理を完結するのではなく、ビジネスロジック内でユーザーからの発話を受け取ることができました。
  • Promiseを作成し、ビジネスロジック中でそれをawaitすることで、ユーザーからの応答があるまでビジネスロジックを止めることができました。
  • Promiseのコールバック関数resolveを保持し、ユーザーからの応答(Webhook)を受けてから保持していたコールバック関数を実行することで、ビジネスロジックを再開させることができました。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?