話題の記事

[電話予約の無人化]Amazon Connect + GPT-4 JSONモード + Whisperで、1回の発話から予約情報(日付,時間など)を抽出

2023.11.21

はじめに

Amazon Connect + GPT-4 JSONモード + Whisperで、1回の発話から予約情報(電話番号,日時,名前,人数)を正しく抽出できるか検証しました。

コールセンターでは、有人対応から無人対応に変更したいニーズが増えているように思います。

電話予約の無人対応を想定し、1回の発話で、下記の5つの予約情報を抽出できるか確認します。

  • お名前
  • 電話番号
  • 予約日
  • 予約時間
  • 人数

発話で予約情報を抽出する方法として、GPT-4 Turbo のJSONモードを利用します。

JSONモードの詳細は、下記を参照ください。

例えば、「名前はクラスメソッドで、電話番号は09011111111。来週の火曜日の19時に4名で予約できますか?」というテキストの場合、予約情報を下記のようにJSON形式で抽出が可能です。

{
    "name": "クラスメソッド",
    "phone_number": "09011111111",
    "date": "20231128",
    "time": "1900",
    "number": "4",
// ~省略~
}

発話した日付が2023年11月20日なので、来週の火曜日は、11月28日になります。

注意点として、JSONモードでは、出力データが正しいJSON形式であることは保証されますが、そのデータが特定のスキーマに一致するかどうかは保証されません

ちなみに、発話から個人情報の聞き取りは、下記で試しました。

構成

構成としては、下記の通りです。

Connectのフローの詳細は下記の通りです。

  1. コンタクトフロー内で「メディアストリーミングの開始」ブロックを使って、Kinesis Video Stream(KVS)への音声のストリーミングを開始します。
  2. 顧客は、予約をするために、予約に必要な情報を発話をします。
  3. 「顧客の入力を保存する」ブロックで、顧客が発話を止めると、ストリーミングを終了します。
  4. 「AWS Lambda関数を呼び出す」ブロックを使い、LambdaでKVSからデータを取得します。取得したデータをWAV形式に変換し、Whisper APIで文字起こしします。文字起こし内容から、GPT-4 Turboで予約情報を抽出します。
  5. 予約情報が抽出できていれば、確認のための音声出力をし、抽出できなければ、オペレーターにエスカレーション(今回は実際にエスカレーションはしません)も可能です。

以下の図は、電話での対話の流れを示しています。

上記の図の最後で、Connectが予約情報の繰り返した後は、ユーザー側でLexを使って「はい」「いいえ」やプッシュボタンによって、フローを分岐させるとよいです。

前提

  • 2023年11月時点での検証内容です。今後のアップデートにより改善される可能性があります。恒久的な結果ではありません。
  • 今回は、いくつかのサンプルで検証を行っただけであり、他のサンプルでも同様の結果となるとは限りません。これらの結果は一例として参照ください。

構築

Lambda

LambdaでWhisper APIやGPT-4 Turboを利用するあたり、OpenAIアカウントAPIキーの発行やOpenAIのPython向けのライブラリをLambdaにアップロードする方法などは、以下の記事を参照ください。

以下の設定を行います

  • 環境変数では、OpenAIのキーを設定
  • タイムアウトは、3秒から30秒に変更
  • OpenAIのPython向けのライブラリをLambdaレイヤーに追加
  • IAMの管理ポリシーAmazonKinesisVideoStreamsReadOnlyAccessを適用
from decimal import Decimal
from datetime import datetime, timedelta, timezone
from ebmlite import loadSchema
from enum import Enum
from botocore.config import Config
import boto3, os, struct, json, openai

openai.api_key = os.environ["API_Key"]

JST = timezone(timedelta(hours=+9))
date = lambda: datetime.now(JST).strftime('%Y%m%d')
time = lambda: datetime.now(JST).strftime('%H%M')
current_date = date()
current_time = time()

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

class Mkv(Enum):
    SEGMENT = 0x18538067
    CLUSTER = 0x1F43B675
    SIMPLEBLOCK = 0xA3

class Ebml(Enum):
    EBML = 0x1A45DFA3

class KVSParser:
    def __init__(self, media_content):
        self.__stream = media_content["Payload"]
        self.__schema = loadSchema("matroska.xml")
        self.__buffer = bytearray()

    @property
    def fragments(self):
        return [fragment for chunk in self.__stream if (fragment := self.__parse(chunk))]

    def __parse(self, chunk):
        self.__buffer.extend(chunk)
        header_elements = [e for e in self.__schema.loads(self.__buffer) if e.id == Ebml.EBML.value]
        if header_elements:
            fragment_dom = self.__schema.loads(self.__buffer[:header_elements[0].offset])
            self.__buffer = self.__buffer[header_elements[0].offset:]
            return fragment_dom

def get_simple_blocks(media_content):
    parser = KVSParser(media_content)
    return [b.value for document in parser.fragments for b in 
            next(filter(lambda c: c.id == Mkv.CLUSTER.value, 
                        next(filter(lambda s: s.id == Mkv.SEGMENT.value, document)))) 
            if b.id == Mkv.SIMPLEBLOCK.value]

def create_audio_sample(simple_blocks, margin=4):
    position = 0
    total_length = sum(len(block) - margin for block in simple_blocks)
    combined_samples = bytearray(total_length)
    for block in simple_blocks:
        temp = block[margin:]
        combined_samples[position:position+len(temp)] = temp
        position += len(temp)
    return combined_samples

def convert_bytearray_to_wav(samples):
    length = len(samples)
    channel = 1
    bit_par_sample = 16
    format_code = 1
    sample_rate = 8000
    header_size = 44
    wav = bytearray(header_size + length)
    
    wav[0:4] = b"RIFF"
    wav[4:8] = struct.pack("<I", 36 + length)
    wav[8:12] = b"WAVE"
    wav[12:16] = b"fmt "
    wav[16:20] = struct.pack("<I", 16)
    wav[20:22] = struct.pack("<H", format_code)
    wav[22:24] = struct.pack("<H", channel)
    wav[24:28] = struct.pack("<I", sample_rate)
    wav[28:32] = struct.pack("<I", sample_rate * channel * bit_par_sample // 8)
    wav[32:34] = struct.pack("<H", channel * bit_par_sample // 8)
    wav[34:36] = struct.pack("<H", bit_par_sample)
    wav[36:40] = b"data"
    wav[40:44] = struct.pack("<I", length)
    
    wav[44:] = samples
    return wav

def create_kvs_client():
    region_name = "ap-northeast-1"
    return boto3.client("kinesisvideo", region_name=region_name)

def create_archive_media_client(ep):
    region_name = "ap-northeast-1"
    return boto3.client("kinesis-video-archived-media", endpoint_url=ep, config=Config(region_name=region_name))

def get_media_data(arn, start_timestamp, end_timestamp):
    kvs_client = create_kvs_client()

    list_frags_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="LIST_FRAGMENTS")["DataEndpoint"]
    list_frags_client = create_archive_media_client(list_frags_ep)

    fragment_list = list_frags_client.list_fragments(
        StreamARN = arn, 
        FragmentSelector = {
            "FragmentSelectorType": "PRODUCER_TIMESTAMP",
            "TimestampRange": {"StartTimestamp": datetime.fromtimestamp(start_timestamp), "EndTimestamp": datetime.fromtimestamp(end_timestamp)}
        }
    )

    sorted_fragments = sorted(fragment_list["Fragments"], key = lambda fragment: fragment["ProducerTimestamp"])
    fragment_number_array = [fragment["FragmentNumber"] for fragment in sorted_fragments]
    print("Received fragment_number_array:" + json.dumps(fragment_number_array,default=decimal_to_int, ensure_ascii=False))


    get_media_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="GET_MEDIA_FOR_FRAGMENT_LIST")["DataEndpoint"]
    get_media_client = create_archive_media_client(get_media_ep)

    media = get_media_client.get_media_for_fragment_list(StreamARN = arn, Fragments = fragment_number_array)
    return media

def save_audio_to_tmp(wav_audio, filename="output.wav"):
    with open(f"/tmp/{filename}", "wb") as out_file:
        out_file.write(wav_audio)

def transcribe_audio_file(file_path):
    with open(file_path, "rb") as audio_file:
        return openai.Audio.transcribe("whisper-1", audio_file)

def extract_json_format(input_text):
    input_text = f"""
    ## 役割
    あなたは、お客さんのお問い合わせから、予約するために必要な情報を抽出し、JSON形式で出力するシステムです。
    ## ルール
    - お客さんからのお問い合わせから、下記の5つ抽出して、例を参考にJSON形式で出力ください。
        - 「名前(name)」
            - 名前は、ひらがな、に変換ください。
        - 「電話番号(phone_number)」
            - 電話番号にハイフン(-)は必要ないので、削除してください。数字のみを出力ください。
        - 「予約の日付(date)」
            - 「予約の日付(day)」は、8桁の数字での日付形式(例"20231118")に変換してください。
            - 今日の日付は、{current_date}、です。「予約の日付(date)」は、未来の値です。
                - 例 3日と言われたら、今月の3日のことです。もし今月の3日が過ぎていれば、翌月の3日です。
                - 例 5月と言われたら、今年の5月のことです。今年の5月が過ぎていれば、翌年の5月です。
                - 今日の予約も可能です。
            - 月曜日が週の最初で週末は土日を指します。
        - 「予約時間(time)」
            - 予約可能日時は、9:00 ~ 20:00です。
            - 5時と言われたら、0500ではなく、1700のことです。
            - 今の時間は、{current_time}、です。「予約の時間(time)」は、未来の値です。
            - 今から予約も可能です。
        - 「人数(number)」
    - 分からない場合、値はnullにしてください。
    - JSON形式以外は出力しないでください。
    ## 例
    {{
      "name": "やまだいちろう",
      "phone_number": "09011111111",
      "date": "20231120",
      "time": "1430",
      "number": "2",
    }}
    ## お問い合わせ
    {input_text}
    """

    response = openai.ChatCompletion.create(
        model="gpt-4-1106-preview",
        messages=[
            {"role": "user", "content": input_text}
        ],
        response_format= { "type":"json_object" },
        temperature=0,
    )
    response_content = response["choices"][0]["message"]["content"]
    response_content = json.loads(response_content)
    print("Received extract_json_format:" + json.dumps(response_content, default=decimal_to_int, ensure_ascii=False))
    return response_content

def convert_reservation_info(reservation_info):
    # 電話番号をカンマ区切りに変換
    if reservation_info.get('phone_number'):
        reservation_info['phone_number_convert'] = ','.join(list(reservation_info['phone_number']))

    # 日付を年月日形式に変換
    if reservation_info.get('date'):
        date = reservation_info['date']
        year = date[:4]
        month = int(date[4:6])
        day = int(date[6:]) 
        reservation_info['date_convert'] = f"{year}年{month}月{day}日"

    # 時間を時分形式に変換
    if reservation_info.get('time'):
        time = reservation_info['time']
        hour = int(time[:2])  
        minute = int(time[2:])
        if minute == 0:
            reservation_info['time_convert'] = f"{hour}時"
        else:
            reservation_info['time_convert'] = f"{hour}時{minute}分"

    print("Received convert_reservation_info:" + json.dumps(reservation_info, default=decimal_to_int, ensure_ascii=False))
    return reservation_info

def check_and_add_result(reservation_info):
    if all(reservation_info.values()):
        reservation_info["result"] = "success"
    else:
        reservation_info["result"] = "failure"

    return reservation_info

def lambda_handler(event, context):
    print("Received event:" + json.dumps(event,default=decimal_to_int, ensure_ascii=False))
    media_streams = event["Details"]["ContactData"]["MediaStreams"]["Customer"]["Audio"]
    stream_arn = media_streams["StreamARN"]
    start_time = float(media_streams["StartTimestamp"]) / 1000
    end_time = float(media_streams["StopTimestamp"]) / 1000
    combined_samples = create_audio_sample(
        get_simple_blocks(get_media_data(stream_arn, start_time, end_time)))

    wav_audio = convert_bytearray_to_wav(combined_samples)
    save_audio_to_tmp(wav_audio)

    transcript = transcribe_audio_file("/tmp/output.wav")
    transcript = str(transcript["text"])
    print("Transcript result:" + transcript)

    reservation_info = extract_json_format(transcript)
    reservation_info = check_and_add_result(reservation_info)

    return convert_reservation_info(reservation_info)

このLambdaは、例として下記のJSONを返します。

{
    "result": "success",
    "name": "クラスメソッド",
    "phone_number": "09011111111",
    "date": "20231128",
    "time": "1900",
    "number": "4",
    "phone_number_convert": "0,9,0,1,1,1,1,1,1,1,1",
    "date_convert": "2023年11月28日",
    "time_convert": "19時"
}

Lambdaでは、以下の処理をしています

  1. KVSからメディアデータの内、録音データを取得しWAV形式に変換します
  2. Whisper APIで文字起こしし、GPT-4 TurboのJSONモードで予約情報を抽出します。
  3. 抽出データから、予約情報が全て取得できていれば、resultキーの値がsuccessになります。
    • 予約情報が全て取得できておらず、値にnullがあれば、処理は終了し、resultキーの値がfailureを返します。
  4. Connectの音声出力用に、取得した予約情報を修正し、JSON形式で返します。

3つのキー名が〇〇_convertは、Connect音声出力用です。

今回は書いていませんが、予約情報をDynamoDBなどに書き込む処理も必要になります。

また、JSONモードでは、上記の5つのキー名である保証がないため、チェックする処理も必要ですが、今回は省略します。

KVSから、メディアデータから録音データを抽出しWAV変換するコード解説は下記をご参考ください。

Connect フロー

Connect全体のフローは、下記のとおりです。

下記のメディアストリーミングの箇所は、裏でLexを利用して、発話が終了したら、録音を終了するフローになっています。

詳細は下記の記事をご参照ください。

録音終了後、先程作成したLambdaで、録音データから文字起こしし、予約情報の抽出を行います。

後半のフローを説明します。

「コンタクト属性を確認する」でresultキーがfailureであれば、「プロンプトの再生」で担当者に変えるよう音声出力します。(実際には、キューを転送するブロックを作成しますが、今回は省略します。)

resultキーがsuccessであれば、「コンタクト属性の設定」ブロックに進みます。

「コンタクト属性の設定」では、Lambdaで返すresult以外の8つキーを保存します。

音声出力の「プロンプトの再生」ブロックに進み、下記の値を設定すると予約情報を読み上げてくれます。

お名前は、$.Attributes.name 、様、
お電話番号は、$.Attributes.phone_number_convert 、
予約日時は、$.Attributes.date_convert 、の
、$.Attributes.time_convert 、
ご予約の人数は、$.Attributes.number 、めいですね。

試してみる

電話での予約を無人対応を想定し、1回の発話で、下記の5つの予約情報を抽出できるか確認します。

  • お名前
  • 電話番号
  • 予約日
  • 予約時間
  • 人数

名前は、 「すごい名前生成器」というサイトで作成された名前を利用します。

JSONモードで抽出する際のプロンプトは、下記のとおりです。

def extract_json_format(input_text):
    input_text = f"""
    ## 役割
    あなたは、お客さんのお問い合わせから、予約するために必要な情報を抽出し、JSON形式で出力するシステムです。
    ## ルール
    - お客さんからのお問い合わせから、下記の5つ抽出して、例を参考にJSON形式で出力ください。
        - 「名前(name)」
            - 名前は、ひらがな、に変換ください。
        - 「電話番号(phone_number)」
            - 電話番号にハイフン(-)は必要ないので、削除してください。数字のみを出力ください。
        - 「予約の日付(date)」
            - 「予約の日付(day)」は、8桁の数字での日付形式(例"20231118")に変換してください。
            - 今日の日付は、{current_date}、です。「予約の日付(date)」は、未来の値です。
                - 例 3日と言われたら、今月の3日のことです。もし今月の3日が過ぎていれば、翌月の3日です。
                - 例 5月と言われたら、今年の5月のことです。今年の5月が過ぎていれば、翌年の5月です。
                - 今日の予約も可能です。
            - 月曜日が週の最初で週末は土日を指します。
        - 「予約時間(time)」
            - 予約可能日時は、9:00 ~ 20:00です。
            - 5時と言われたら、0500ではなく、1700のことです。
            - 今の時間は、{current_time}、です。「予約の時間(time)」は、未来の値です。
            - 今から予約も可能です。
        - 「人数(number)」
    - 分からない場合、値はnullにしてください。
    - JSON形式以外は出力しないでください。
    ## 例
    {{
      "name": "やまだいちろう",
      "phone_number": "09011111111",
      "date": "20231120",
      "time": "1430",
      "number": "2",
    }}
    ## お問い合わせ
    {input_text}
    """

1つめ

  • モデル:gpt-4-1106-preview、gpt-3.5-turbo-1106
  • 発話内容:さとうかずまです。電話番号は09012345678です。明日の3時に5名で予約できますか?
  • Whisperでの文字起こし:佐藤一馬です。電話番号は090-12345678です。 明日の3時に5名で予約できますか?
  • GPT-4 Turbo JSONモードでの抽出:下記の通りです
{
    "name": "さとういちま",
    "phone_number": "09012345678",
    "date": "20231121",
    "time": "1500",
    "number": "5",
    "phone_number_convert": "0,9,0,1,2,3,4,5,6,7,8",
    "date_convert": "2023年11月21日",
    "time_convert": "15時"
}

gpt-4-1106-previewgpt-3.5-turbo-1106どちらも同じ結果でした。

電話番号や予約日、時間、人数は全く問題ないですね。

ただし、「さとうかずま」という発話をWhisperでは、「佐藤一馬」と勝手に漢字に変換され、JSONモードで抽出とひらがな変換時に、「さとういちま」と誤変換されました。

Whisperでテキスト化する際に、名前をひらがなのままにできればよいのですが、Whisperのプロンプト箇所を修正しても漢字のままでした。

予約時間が3時の場合、プロンプトに記載している通り、予約可能時間が9:00~20:00なので15時と解釈してくれています。

2つめ

  • モデル:gpt-4-1106-preview、gpt-3.5-turbo-1106
  • 発話内容:やまだあいです。電話番号は012012345678です。来月の月末の火曜日の10時半にお店を利用したいです。人数は私だけです。
  • Whisperでの文字起こし:山田愛です 電話番号は0120-12345678です 来月の月末の火曜日の10時半にお店を利用したいです 人数は私だけです
  • GPT-4 Turbo JSONモードでの抽出:下記の通りです

gpt-3.5-turbo-1106は下記です。

{
    "name": "やまだあい",
    "phone_number": "012012345678",
    "date": "20231227",
    "time": "1030",
    "number": "1",
    "result": "success",
    "phone_number_convert": "0,1,2,0,1,2,3,4,5,6,7,8",
    "date_convert": "2023年12月27日",
    "time_convert": "10時30分"
}

gpt-4-1106-previewは下記です。

{
    "name": "やまだあい",
    "phone_number": "012012345678",
    "date": "20231226",
    "time": "1030",
    "number": "1",
    "result": "success",
    "phone_number_convert": "0,1,2,0,1,2,3,4,5,6,7,8",
    "date_convert": "2023年12月26日",
    "time_convert": "10時30分"
}

「来月の月末の火曜日」に対して、gpt-3.5-turbo-1106は、2023年12月27日と出力していますが、間違いです。

gpt-4-1106-previewは、2023年12月26日と出力しており、正しいです。

私と伝えると人数が1人と解釈してくれています。

結果

今回の検証での認識とその評価は、以下の結果となりました。

項目 認識 備考
電話番号 問題なく認識した
名前 一部誤認識あり
予約日 問題なく認識した
予約時間 問題なく認識した
人数 問題なく認識した

gpt-4-1106-previewの場合、電話番号、予約日、予約時間、人数に関しては、問題なく認識しました。

名前は、Whisperで勝手に漢字変換され、抽出時にひらがなに戻すため、一部で誤認識がみられました。

どなたかの参考になれば幸いです。