Amazon ConnectとLexでのコールセンター向けAIチャットボットで、Function Callingを利用し、発話内容から必要な情報を補正しつつJSON形式で抽出してみた

2023.10.18

はじめに

Amazon ConnectとAmazon Lexを組み合わせて、コールセンター向けのAIチャットボットを作成しました。その中でFunction Callingを利用し、発話内容から必要な情報をJSON形式で抽出する方法について記事にまとめました。

Function CallingはAI(GPT-4などのモデル)が事前に定義された特定の関数を実行し、その結果を返す機能のことを指します。例えば、ユーザーから受け取った入力から、必要な情報を抽出しJSON形式で出力することが可能です。

ユーザーの発話からFunction Callingで必要な情報のみを抽出してJSON形式に変換後は、要件に応じて抽出内容を使い、Amazon DynamoDBやAmazon RDSなどのデータベースに抽出内容を書き込んだり、抽出内容からAmazon Kendraでの検索結果をもとに生成AIで回答する(Retrieval Augmented Generation)などで利用できます。

今回は、Function Callingでユーザーの発話内容からアーティスト名と曲名のみを抽出し、抽出内容をそのままConnectで音声出力します。

構成図は、下記の通りです

おおまかな処理内容は、下記の流れです

  1. 発話内容は、Connect経由でLexで文字起こしする
  2. 文字起こしした内容は、Lambdaを使いFunction Callingでアーティスト名と曲名のみを抽出し、JSON形式に出力
  3. 出力した内容はConnectで音声出力

テスト内容とデモ動画

今回のテストでは、以下の3つの発話内容から、それぞれアーティスト名と曲名のみが抽出できるか確認します

  • Official髭男のプリテンダーっていう曲を聞きたいんですが知ってますか
    • Official髭男dismが正式名称ですが、dismを言わないようにしてます(ひげだんと言わない理由は後述します)
  • ジブリ映画にあった大橋のぞみが歌っていた坂の上の崖の上のポニョをかけて
    • 「坂の上の」は、フェイクとしてあえていれてます
  • 上をむいて歩こうっていう1960年台の曲で、坂本九が歌手だった気がする
    • 昔の曲でも可能か確認します

下記は、ユーザーとAIチャットボットのやりとりのイメージ図です。

下記がデモ動画です

「電話をきります」と言うタイミングが若干ずれている点はご了承ください。

Function CallingにはChatGPT4を使っているのですが、レスポンスが早いことに感動しました。

また、一部で違和感がある部分もありましたが、それぞれアーティスト名と曲名の返答は得られました。

髭男は「ひでおとこ」と音声出力されますが、これは、AIではなくConnectの音声出力によるものです。

「髭男」という漢字を、ひらがなやカタカナに変換してConnectに渡すことで、「ひげだん」と音声出力させることは可能ですね。

検証結果の詳細は、後述します。

リソースの構築手順は、下記の順番で説明します。

  1. Lexボットの構築
  2. Lambdaの作成
  3. Connectのフロー作成

Lexボットを構築

Lexのボットとインテントを構築します。

ボット名をつけて、IAMロールは新規作成にします。

言語は、日本語にします。

後の作業になりますが、後で作成するLambdaをLexから呼び出すため、Lambdaを指定し保存します。

Lambdaを呼び出す設定は、分かりにくいのですが、[エイリアス]→対象のエイリアス(TestBotAlias)→[言語:Japanese (Japan)]を順にクリックすると、呼び出す設定画面がでます。

インテント名は、free、インテントを呼び出すためのサンプル発話は、質問にします。

スロット設定は以下です

  • スロット名はfreeinput
  • スロットタイプは、自由形式の入力を受け付けるAMAZON.FreeFormInput
  • プロンプトは、「アーティスト名と曲名をお伝えください」

Lambdaを使用するので、コードフックの項目はチェックします。

これで、ビルドするとLexの構築は完了です。

Lambdaの作成

設定方法は、下記の記事通りに作成ください。記事ではコードの解説もしてます。

コードは下記の通りです。

import json
import os
import openai
from decimal import Decimal
openai.api_key = os.environ['API_Key']

def function_calling(prompt):
    messages = [
        {'role': 'user', 'content': prompt},
    ]
    functions = [
        {
            'name': 'get_artist_and_song_title',
            'description': 'アーティスト名と曲名を取得します',
            'parameters': {
                'type': 'object',
                'properties': {
                    'artist': {
                        'type': 'string',
                        'description': 'アーティスト名。アーティスト名が省略されている場合、フルネームにしてください。 例:Ado '
                    },
                    'song': {
                        'type': 'string',
                        'description': '曲名。 曲名が省略されている場合、フルネームにしてください。例:うっせぇわ '
                    },
                },
                'required': ['artist', 'song']
            }
        }
    ]
    response = openai.ChatCompletion.create(
        # model='gpt-3.5-turbo-0613',
        model='gpt-4-0613',
        messages=messages,
        functions=functions,
        temperature=0,
        max_tokens=512,
        # function_call='auto'
        function_call={'name': 'get_artist_and_song_title'}
    )

    print(json.dumps(response['choices'][0], default=decimal_to_int, ensure_ascii=False))
    return json.loads(response['choices'][0]['message']['function_call']['arguments'])

def decimal_to_int(obj):
    if isinstance(obj, Decimal):
        return int(obj)

def elicit_slot(slot_to_elicit, intent_name, slots):
    return {
        'sessionState': {
            'dialogAction': {
                'type': 'ElicitSlot',
                'slotToElicit': slot_to_elicit,
            },
            'intent': {
                'name': intent_name,
                'slots': slots,
                'state': 'InProgress'
            }
        }
    }

def confirm_intent(message_content, intent_name, slots):
    return {
        'messages': [{'contentType': 'PlainText', 'content': message_content}],
        'sessionState': {
            'dialogAction': {
                'type': 'ConfirmIntent',
            },
            'intent': {
                'name': intent_name,
                'slots': slots,
                'state': 'Fulfilled'
            }
        }
    }

def close(fulfillment_state, message_content, intent_name, slots):
    return {
        'messages': [{'contentType': 'PlainText', 'content': message_content}],
        'sessionState': {
            'dialogAction': {
                'type': 'Close',
            },
            'intent': {
                'name': intent_name,
                'slots': slots,
                'state': fulfillment_state
            }
        }
    }

def free_intent(event):
    print('Received event:' + json.dumps(event,default=decimal_to_int, ensure_ascii=False))
    intent_name = event['sessionState']['intent']['name']
    slots = event['sessionState']['intent']['slots']
    user_prompt = event['inputTranscript']

    if slots['freeinput'] is None:
        return elicit_slot('freeinput', intent_name, slots)

    confirmation_status = event['sessionState']['intent']['confirmationState']

    if confirmation_status == 'Confirmed':
        return close('Fulfilled', 'それでは、電話を切ります', intent_name, slots)

    elif confirmation_status == 'Denied':
        return close('Failed', 'お力になれず、申し訳ありません。電話を切ります', intent_name, slots)

    # confirmation_status == 'None'
    get_artist_and_song = function_calling(user_prompt)
    artist_name = get_artist_and_song['artist']
    song_name = get_artist_and_song['song']

    return confirm_intent(
        f'それでは、回答します。アーティスト名は{artist_name}、曲名は{song_name}。以上が回答になります。回答に納得したかたは、はい、とお伝え下さい。納得いかない場合、いいえ、とお伝え下さい',
        intent_name, slots)


def lambda_handler(event, context):
    print('Received event:' + json.dumps(event,default=decimal_to_int, ensure_ascii=False))

    intent_name = event['sessionState']['intent']['name']

    if intent_name == 'free':
        return free_intent(event)
  • 関数名:elicit_slot
    • Lexのスロットの値が埋まっていない場合に使用します。
  • 関数名:confirm_intent
    • Lexのスロットが全て埋まった時に使用します。
      • 確認プロンプトに設定しているプロンプトを伝えます。今回の場合、「それでは、回答します。.......」になります
  • 関数名:close
    • 確認プロンプトの後、インテントを終了するときに使用します。
      • クローズ時に設定しているプロンプトを聞きます。
  • if slots['freeinput'] is None:このうち、freeinputは、Lexのスロット名です。
  • if intent_name == 'free':このうち、freeは、Lexのインテント名です。

Connectのコンタクトフローを作成

Connectインスタンスは、構築済みの前提です。

対象のConnectインスタンスに、作成したLexとLambdaを登録します

フローはシンプルで、下記の5つのブロックのみにしました。

音声設定は、Kazuhaにしてます。言語属性を設定のチェックも忘れずにしましょう。

[顧客の入力を取得する]ブロックでは、読み上げるテキストを入力し、Lexのボット名やエイリアス名、インテント名を記載して保存します。

フロー公開後、電話番号をフローに割り当てば、Connectの設定は、完了です。

電話をかけて、発話すると、発話内容からアーティスト名と曲名を抽出して回答してくれます。

検証結果

今回の発話内容は、以下の3つであり(再掲)、それぞれアーティスト名と曲名が抽出できるかデモ動画の通り確認しました。

  • Official髭男のプリテンダーっていう曲を聞きたいんですが知ってますか
    • Official髭男dismが正式名称ですが、dismを言わないようにしてます
  • ジブリ映画にあった大橋のぞみが歌っていた坂の上の崖の上のポニョをかけて
    • 「坂の上の」は、フェイクとしてあえていれてます
  • 上をむいて歩こうっていう1960年台の曲で、坂本九が歌手だった気がする

再掲ですが処理内容は、下記の流れです

  1. 発話内容は、Connect経由でLexで文字起こしする
  2. 文字起こしした内容は、Lambdaを使いFunction Callingでアーティスト名と曲名のみを抽出し、JSON形式に出力
  3. 出力した内容はConnectで音声出力

LexとFunction Callingの出力結果

処理内容の中で、Lexで文字起こしした内容とFunction CallingでJSON形式に出力された内容を確認します。

  • 発話内容「Official髭男のプリテンダーっていう曲を聞きたいんですが知ってますか」
    • Lexでの文字起こし「official 髭 男 の pretender って いう 曲 を 聞き たい ん です けど 知っ て ます か」
    • Function Callingで抽出
      • アーティスト名:official 髭 男 dism
      • 曲名:pretender

1つ目の発話では、Lexの文字起こし時点では「official 髭 男」でしたが、Function Callingによって「official 髭 男 dism」に変換されました。ChatGPT4の性能によるものですね。(無意味なスペースが入ってますが、削除方法は後述します)

  • 発話内容「ジブリ映画にあった大橋のぞみが歌っていた坂の上の崖の上のポニョ」
    • Lexでの文字起こし「ジブリ 映画 に あっ た オオハシ 希望 が 歌っ て い た 坂上 崖 の 上 の ポニョ を かけ て」
    • Function Callingで抽出
      • アーティスト名:オオハシ希望
      • 曲名:崖の上のポニョ

2つ目の発話では、曲名は問題なく抽出できましたが、アーティスト名は、Lexの文字起こしの時点で 「オオハシ希望」でした。

このLexの文字起こし部分は、調整できないため、今後のLexの文字起こし精度の向上に期待しましょう。(厳密にはAmazon Transcribeの精度向上)

  • 発話内容「上をむいて歩こうっていう1960年台の曲で、坂本九が歌手だった気がする」
    • Lexでの文字起こし「上 を 向い て 歩こう って いう 千 九 百 六 十 年 代 の 曲 で サカモト 九 が 下 四 だっ た 気 が する」
    • Function Callingで抽出
      • アーティスト名:坂本九
      • 曲名:上を向いて歩こう

3つ目の発話では、Lexの文字起こし時点では「サカモト 九」でしたが、Function Callingによって「坂本九」に変換されました。

3つの発話を全体的に見ると、Lexは漢字や英語に適切に変換しており、その文字起こしの精度は比較的高いと印象です。

正式名称に変換できないパターン

ちなみに、今回は発話内容は「Official髭男」でしたが、「ひげだん」という発話では、正式名称に変換されませんでした。

  • 発話内容「ひげだんのプリテンダー」
    • Lexでの文字起こし「ヒゲダン の pretender」
    • Function Callingで抽出
      • アーティスト名:ひげだん
      • 曲名:pretender

LexやChatGPT(Function Calling)の性能向上に期待しましょう。

Lexの文字起こしたスペースを削除

Lambda実行時のレスポンスは、JSON形式で下記のように、文字列の間にスペースが入っています。

{
  "artist": "official 髭 男 dism",
  "song": "pretender"
}

理由としては、Lexで文字起こししたときにスペースが入るためです。

Function Calling時に、スペースを削除したいので、descriptionにスペースを削除する文言を加えました。

# 省略...

functions = [
    {
        "name": "get_artist_and_song_title",
        "description": "アーティスト名と曲名を取得します。スペースを含むアーティスト名はスペースを削除してください。",
        "parameters": {
            "type": "object",
            "properties": {
                "artist": {
                    "type": "string",
                    "description": "アーティスト名。アーティスト名が省略されている場合、フルネームにしてください。例:Ado"
                },
                "song": {
                    "type": "string",
                    "description": "曲名。 曲名が省略されている場合、フルネームにしてください。例:うっせぇわ "
                },
            },
            "required": ["artist", "song"]
        },
    },

# 省略...

これで発話すると、スペースが削除された状態でレスポンスを返すことができました。

{
  "artist": "official髭男dism",
  "song": "pretender"
}

最後に

今回は、ConnectとLexでのチャットボットを用いて、ユーザーの発話からFunction Callingで必要な情報のみをJSON形式で抽出しました。

要件に応じて抽出内容を用いて、DBに書き込みを行ったり、Kendraを使いRAGとして利用したり、ユーザーに対して音声出力をすることができますので、用途は幅広いです。

Function Callingでの抽出処理時に、正式名称に変換などのある程度補正をかけてくれましたが、完璧ではありません。

LexやChatGPTの性能は今後さらに向上していくと思いますので、その発展に期待しています。