Amazon Bedrockで発話での注文から、商品名と数量を抽出し、商品マスタの商品名と突合してみた[AIチャットボット]

2024.04.17

はじめに

Amazon Connect、Amazon Bedrock、Whisper APIを組み合わせて、電話で発話された注文内容(商品名と数量)を認識し、商品マスタと突合する方法とその精度を検証しました。

精度の確認方法は、発話された商品名と数量がAWS Lambdaのログで発話通りに認識されているかを確認しました。

利用シーンとしては、電話での注文を無人対応するケースです。注文をヒアリング後は、自動で発注、もしくはオペレーターにエスカレーションが考えられます。

電話での対話の流れは、以下のようなイメージです。

発話した商品名と商品マスタの商品名を、生成AIを利用して突合することができれば、自動で発注が実現できます。

注文内容を復唱後、ユーザーには発話やプッシュ式で、問題ないか回答してもらうとよいです。

構成

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

ヒアリング(注文)内容から商品マスタの商品名と突合するConnectフローは、下記の通りです。

  1. コンタクトフロー内で「メディアストリーミングの開始」ブロックを使って、Kinesis Video Streams(KVS)への音声のストリーミングを開始します。
  2. 商品名と数量を発話します。
  3. 「顧客の入力を保存する」ブロックで、顧客が発話を止めると、ストリーミングが終了します。Amazon Lexを裏で呼び出しています。
  4. 「AWS Lambda関数を呼び出す」ブロックを使い、LambdaでKVSからデータを取得します。取得したデータをWAV形式に変換し、Whisper APIで文字起こしします。文字起こし内容から、Amazon BedrockのClaudeで商品名と数量を抽出 + 商品名と最も類似した商品マスタ内の商品名を特定します。
  5. 商品マスタ内の商品名と類似した商品名が特定できれば、確認のための音声出力をし、できなければ、その旨を伝えます。
    • 今回はしませんが、オペレーターにエスカレーションなども可能です。

商品マスタはAmazon DynamoDBなどのデータベースに保存されることを想定していますが、簡略化のため、Lambdaのコードに直接商品名のリストを入れています。

今回は、商品マスタにある商品数は600点にしており、BedrockのClaudeに商品マスタの商品名を全てインプットしています。

ただし、商品数が数千点を超える場合、Claudeのプロンプトの最大入力トークン数を超えたり、レスポンス時間が長くなる可能性があり、Claudeを使えないケースも考えられます。

そのため、商品数が多い場合、Amazon Kendraを選定するとよいです。商品マスタをAmazon RDS等に保存し、Kendraにテーブル情報をもとにインデックスを作成することで実現できます。

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

注文内容を復唱後、ユーザーに発話やプッシュ式で回答してもらうとよいでしょう。

前提

  • 2024年4月12日時点での検証内容です。
  • 発話のイントネーションや速度、周囲の雑音などにより検証結果が異なる可能性がありますので、ここでの結果は一例とご認識ください。
  • 今後のバージョンアップによって、改善される可能性があり、恒久的な結果ではありません。

構築

以下の構築を解説します。

  • Lambda
  • Connectフロー

AWS Lambda

ユーザーの発話内容を「メディアストリーミングの開始」ブロックを使って、KVSに保存後、Lambdaで以下の処理を行います。

  1. LambdaでKVSからメディアデータを取得します。
  2. メディアデータから音声データを抽出し、WAV形式に変換後、Lambdaのローカルに保存します。
  3. Whisper APIで音声ファイルに対して、文字起こしを実行します。
  4. Bedrock のClaudeで文字起こし内容から以下の処理を行ってもらいます
    • 商品名と数量を抽出
    • 抽出した商品名に対して、最も類似した商品マスタ内の商品名を特定します。
  5. Connectフローに対して、商品名と数量を返します。(その後、Connectフローのプロンプトの再生で商品名と数量を音声出力)

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

また、MKVファイルの解析にはebmliteライブラリを使用します。このライブラリを使用する場合は、先にZIP化してLambda レイヤーにアップロードしておきます。

$ python3 -m pip install -t ./python ebmlite
$ zip -r ebmlite-3.3.1.zip ./python

以下の設定を行います

  • 環境変数は、OpenAIのキーを設定
  • タイムアウトは、3秒から20秒に変更
  • メモリは512MB
  • Lambdaレイヤーに追加
    • OpenAIのPython向けのライブラリ
    • ebmlite
  • IAMの管理ポリシーを適用
    • AmazonKinesisVideoStreamsReadOnlyAccess
    • AmazonBedrockFullAccess

以下がLambdaのコードです。コードにおいて、上記の1と2の処理は、以下の記事で詳細に解説していますので、ご参照ください。

from datetime import datetime
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"]

PRODUCT_NAME_MASTER = """
    チョコレートケーキ
    プリン
    クッキー
    ドーナツ

    ~省略~
    """

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):
    total_length = sum(len(block) - margin for block in simple_blocks)
    combined_samples = bytearray(total_length)
    position = 0
    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_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 = boto3.client("kinesisvideo")
    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": start_timestamp, "EndTimestamp": end_timestamp}
        }
    )

    sorted_fragments = sorted(fragment_list["Fragments"], key=lambda fragment: fragment["ProducerTimestamp"])
    fragment_number_array = [fragment["FragmentNumber"] for fragment in sorted_fragments]

    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 convert_ms_to_datetime(timestamp_ms_str, add_seconds=1):
    timestamp_seconds = float(timestamp_ms_str) / 1000 + add_seconds
    return datetime.utcfromtimestamp(timestamp_seconds)

def transcribe_audio_file(file_path):
    with open(file_path, "rb") as audio_file:
        transcript = openai.Audio.transcribe("whisper-1", audio_file)
        transcript_text = transcript["text"]
        print('transcript:' + json.dumps(transcript_text, ensure_ascii=False))
        return transcript_text

def extract_products_and_quantities(input_text):

    bedrock_runtime = boto3.client('bedrock-runtime', region_name="us-east-1")
    prompt = f"""\n\nHuman:お客さんのお問い合わせ内容から、ルールに遵守して商品DBの商品名と数量を特定し、JSON形式で出力してください。
    <product_name_db>
        {product_name_db}
    </product_name_db>
    <rule>
        - 商品名はproduct_name、数量はquantityです。
        - 数量の記載が無い場合、数量は1つです。
        - 商品名と数量が複数の場合もあります。
        - お問い合わせ内容の中で数量は、途中で増やしたり減らしたりするなど、言い直すことがあります。反映しましょう。
            - 例:チョコレートケーキを3つください。やっぱり1つにしてください
        - お問い合わせ内容の中で商品は、変更したり、キャンセルすることがあります。反映しましょう。
        - 音声を文字起こししているため、product_name_dbの商品名と完全一致しない場合もあります。文字起こし内容は補完して、最も類似した商品名を特定してください。
        - 読み取れない場合、result値はfailureにしてください。読み取れた場合、result値はsuccessにしてください。
        - 応答は、JSON形式で出力ください。JSON形式以外に、文章などで出力しないでください。
    </rule>
    ## 例1
    {{
        "items": [
            {{
            "product_name": "チョコレートケーキ",
            "quantity": 1
            }}
        ],
        "result": "success"
    }}
    ## 例2
    {{
        "items": [
            {{
            "product_name": "チョコレートケーキ",
            "quantity": 1
            }},
            {{
            "product_name": "マーブルブラウニー",
            "quantity": 12
            }}
        ],
        "result": "success"
    }}
    ## 例3
    {{
        "items": [{{}}],
        "result": "failure"
    }}
    <sentence>
        {input_text}
    </sentence>
    Assistant:{{
    """

    # modelId = 'anthropic.claude-3-haiku-20240307-v1:0'
    modelId = 'anthropic.claude-3-sonnet-20240229-v1:0'
    accept = 'application/json'
    contentType = 'application/json'

    body = json.dumps({
        "anthropic_version": "bedrock-2023-05-31",
        "messages": [
            {
                "role": "user",
                "content": prompt
            }
        ],
        "max_tokens": 400,
        'temperature': 0,
    })

    response = bedrock_runtime.invoke_model(
        modelId=modelId,
        accept=accept,
        contentType=contentType,
        body=body
    )

    response_body = json.loads(response.get('body').read())
    print("bedrock response_body:", json.dumps(response_body, ensure_ascii=False))
    response_text = response_body["content"][0]["text"]
    items = json.loads(response_text)
    
    return items

def generate_message(product_info):
    items = product_info.get('items', [])
    message_parts = []
    for item in items:
        product_name = item['product_name']
        quantity = item['quantity']
        message_parts.append(f"{product_name}を、数量、{quantity}")

    return "、".join(message_parts)

def generate_confirmation_message(product_info):
    if product_info.get('result') == 'failure':
        result = {
            "result": "failure"
        }
        print('return response:' + json.dumps(result, ensure_ascii=False))
        return result

    message = generate_message(product_info)
    items = product_info.get('items', [])
    result = {
        "result": "success",
        "message": message
    }
    for i, item in enumerate(items, start=1):
        result[f"item{i}"] = {
            "product_name": item['product_name'],
            "quantity": item['quantity']
        }
    print('return response:' + json.dumps(result, ensure_ascii=False))
    return result
    
def lambda_handler(event, context):
    print('event:' + json.dumps(event, ensure_ascii=False))
    media_streams = event["Details"]["ContactData"]["MediaStreams"]["Customer"]["Audio"]
    stream_arn = media_streams["StreamARN"]

    start_timestamp = convert_ms_to_datetime(media_streams["StartTimestamp"])
    end_timestamp = convert_ms_to_datetime(media_streams["StopTimestamp"])

    combined_samples = create_audio_sample(
        get_simple_blocks(get_media_data(stream_arn, start_timestamp, end_timestamp)))

    wav_audio = convert_bytearray_to_wav(combined_samples)
    save_audio_to_tmp(wav_audio)

    transcript = transcribe_audio_file("/tmp/output.wav")

    product_info = extract_products_and_quantities(transcript)

    return generate_confirmation_message(product_info)

PRODUCT_NAME_MASTERは、623商品ありますので、上記のコードでは省略しています。実際のコードは以下です。 623もの商品名は、生成AIを使って考えたもので、ダブっている可能性があります。ご了承ください。

PRODUCT_NAME_MASTER (クリックすると展開します)
    チョコレートケーキ
    プリン
    クッキー
    ドーナツ
    アイスクリーム
    ゼリー
    キャラメル
    マカロン
    パンナコッタ
    タルト
    モンブラン
    エクレア
    シュークリーム
    パイ
    ブラウニー
    ムース
    パフェ
    フルーツタルト
    チーズケーキ
    ロールケーキ
    ラングドシャ
    オペラ
    サバラン
    サンマルク
    ガレット・デ・ロワ
    ガレット・ブルトンヌ
    クランペット
    ビアンコレッリ
    マリトッツォ
    ボッサ
    コッペパーン
    ルスク
    ラスク
    ビスコッティ
    カンターレ
    アマレッティ
    ペストリー
    ペストゥッチョ
    コルネッティ
    グリッシーニ
    クレープ
    パンケーキ
    ワッフル
    スコーン
    マドレーヌ
    フィナンシェ
    サブレ
    ガレット
    メレンゲ
    ベーグル
    プレツェル
    ポップコーン
    キャンディー
    チョコレート
    ゼリービーンズ
    マシュマロ
    トフィー
    ナッツ
    ドライフルーツ
    グミ
    クラッカー
    プリッツ
    ポテトチップス
    スナック菓子
    せんべい
    おかき
    かりんとう
    あめ
    ラムネ
    ヨーグレット
    アーモンドケーキ
    バウムクーヘン
    カステラ
    クグロフ
    ストレーゼル
    ダニッシュ
    クロワッサン
    パン・オ・ショコラ
    パン・オ・レザン
    ブリオッシュ
    クイニーアマン
    バゲット
    食パン
    菓子パン
    デニッシュ
    シュトーレン
    パネトーネ
    ビスケット
    マフィン
    プレッツェル
    ケーキ
    シャーベット
    ガトーショコラ
    プチフール
    ミルフィーユ
    カヌレ
    ババロア
    ブランマンジェ
    クレームブリュレ
    アーモンドトースト
    フィユタージュ
    サブレサンディッチ
    パウンドケーキ
    バナナブレッド
    ジンジャーブレッド
    コーンブレッド
    バターロール
    シナモンロール
    ストリュッセル
    トルティーヤチップス
    トレイルミックス
    グラノーラバー
    ミューズリーバー
    フルーツバー
    プロテインバー
    エナジーバー
    ライスクリスピートリート
    マシュマロトリート
    ブレッツェル
    スティックプレッツェル
    スナックミックス
    ミックスナッツ
    ピーナッツ
    アーモンド
    カシューナッツ
    マカデミアナッツ
    ピスタチオ
    ヘーゼルナッツ
    クルミ
    ピーカンナッツ
    松の実
    ビーフジャーキー
    ターキージャーキー
    梅干し
    数の子
    塩わらび餅
    水ようかん
    いちごようかん
    くりようかん
    栗きんとん
    甘納豆
    落雲
    半熟最中
    大福
    柏餅
    羊羹
    小倉羊羹
    笹団子
    草もち
    いなり寿司
    手まり寿司
    桜もち
    粽
    ちまき
    おはぎ
    田作り
    黒棗
    伊達巻
    赤飯
    小豆羹
    小豆がゆ
    芋がゆ
    芋ようかん
    栗渋皮煮
    干し芋
    干し柿
    干し梅
    煎り大豆
    塩昆布
    塩豆
    豆菓子
    落花生
    ピーナッツバター
    ヘーゼルナッツバター
    アーモンドバター
    ココナッツバター
    はちみつ
    メープルシロップ
    アガベシロップ
    ゴールデンシロップ
    黒蜜
    ラムネ菓子
    ポップロック
    フィズ菓子
    キャンデーボタン
    チョコレートコーティングスナック
    チョコレートバー
    ウエハース
    クレープデンタル
    クレープロール
    パイ包み菓子
    揚げパン
    クリームパン
    あんパン
    コーヒーブレッド
    メロンパン
    クリームボックス
    ホットドッグパン
    ピザパン
    カレーパン
    パンプキンブレッド
    レーズンブレッド
    クランベリーブレッド
    ズッキーニブレッド
    パンオショコラ
    パンオレザン
    チョコレートブレッド
    シナモンブレッド
    ライ麦パン
    全粒粉パン
    ミルクパン
    ハニーブレッド
    イングリッシュマフィン
    トルティーヤ
    ナン
    パリパリ
    ピタパン
    フォカッチャ
    チャバタ
    ブリトー
    ケサディーヤ
    タコス
    ナチョス
    エンパナーダ
    サモサ
    スプリングロール
    ワンタン
    ギョーザ
    焼売
    蒸しパン
    ベーキングパウダー
    ココナッツマカロン
    ラデッキオ
    カンタッチ
    スフォリアテッレ
    パンドーロ
    コロンバ
    レブクーヘン
    ドライケーキ
    スノーボール
    クーグロフ
    ババ
    サヴァラン
    ダックワーズ
    リングケーキ
    バンドケーキ
    シフォンケーキ
    アングルフード
    ジェノワーズ
    マーブルケーキ
    レアチーズケーキ
    ベイクドチーズケーキ
    ノーベイクチーズケーキ
    ティラミス
    ラムボール
    トリュフ
    ギリシャ菓子
    ハルヴァ
    ルクム
    ブルマ
    カタイフ
    ガラクトブリコ
    レヴァニ
    ダクチェル
    ポルトガル菓子
    パステルデナタ
    トラヴェッセイロス
    オフォス
    セーラ
    アルフェロス
    ボリーノス
    フィリピン菓子
    ビビンガ
    ハワイ菓子
    ハウピア
    リリコイバター
    マラサダ
    マレンギーナ
    アモーレス
    アーユーレート
    ロシア菓子
    プラリネ
    トルテ
    シュー
    キセリ
    パフテリ
    インド菓子
    グラブジャムン
    ラスグッラ
    ジャレビ
    バルフィ
    ラッシーマライ
    ソーンパプリ
    モチ
    大福もち
    いちご大福
    小豆大福
    抹茶大福
    焼き大福
    生大福
    ずんだ大福
    羊かん
    小倉ようかん
    柚子ようかん
    抹茶ようかん
    白ごまようかん
    黒ごまようかん
    胡麻ようかん
    栗ようかん
    紫芋ようかん
    マーラーカオ
    ファーカオ
    サラパオ
    ヌンジャム
    バオズ
    小籠包
    ニューリーパイ
    エッグタルト
    ポルトガルエッグタルト
    マカオエッグタルト
    カスタードタルト
    ミルクタルト
    チョコタルト
    キャラメルタルト
    ナッツタルト
    チーズタルト
    ガレットデロワ
    ガレットブルトンヌ
    ガレットコンプレット
    ミルクレープ
    チョコレートクレープ
    フルーツクレープ
    ベルギーワッフル
    リエージュワッフル
    ストロベリーワッフル
    チョコレートワッフル
    バナナワッフル
    アイスクリームワッフル
    ワッフルサンド
    ワッフルケーキ
    チャイワッフル
    チーズワッフル
    マーブルブラウニー
    ホワイトチョコブラウニー
    フィリングブラウニー
    キャラメルブラウニー
    ナッツブラウニー
    ブラウニーパイ
    ブラウニーチーズケーキ
    ブラウニーマフィン
    ブラウニーパンケーキ
    ブラウニーアイスクリーム
    チュロス
    ビーニャ
    ソパイピリャ
    ポリヴォロネス
    トルタデアセイテ
    ロスコーネス
    マントウ
    ダンゴ
    小豆もち
    粟おはぎ
    きなこもち
    黒糖もち
    抹茶もち
    紫芋もち
    安納芋もち
    小豆きんとん
    白きくらげ
    鶴の子
    鶴の巣
    鳳凰の巣
    龍眼ゼリー
    ココナッツゼリー
    グレープゼリー
    マンゴープリン
    ココナッツプリン
    ブリュレ
    アイスクリームサンデー
    アイスクリームフロート
    アイスクリームソーダ
    アイスクリームパフェ
    アイスクリームケーキ
    アイスクリームロール
    アイスクリームサンド
    アイスキャンディー
    アイスバー
    アイスクリームコーン
    アイスクリームカップ
    アイスクリームブリック
    アイスクリームパイ
    アイスクリームタルト
    アイスクリームパン
    アイスクリームドーナツ
    アイスクリームクレープ
    アイスクリームブラウニー
    アイスクリームロールケーキ
    アイスクリームモンブラン
    アイスクリームスムージー
    アイスクリームシェイク
    アイスクリームマルシェ
    アイスクリームバナナスプリット
    アイスクリームパルフェ
    アイスクリームフォンデュ
    アイスクリームチュロス
    アイスクリームタコス
    アイスクリームブリトー
    アイスクリームサモサ
    アイスクリームスプリングロール
    アイスクリームエッグワッフル
    アイスクリームホットドッグ
    アイスクリームピザ
    アイスクリームブリオッシュ
    アイスクリームベーグル
    アイスクリームクロワッサン
    アイスクリームデニッシュ
    アイスクリームマフィン
    アイスクリームスコーン
    アイスクリームシナモンロール
    アイスクリームエクレア
    アイスクリームプロフィットロール
    アイスクリームシュークリーム
    アイスクリームカヌレ
    アイスクリームフィナンシェ
    アイスクリームマドレーヌ
    アイスクリームサブレ
    アイスクリームガレット
    アイスクリームクグロフ
    アイスクリームバームクーヘン
    フォンダンショコラ
    ムースオショコラ
    トリュフショコラ
    ガナッシュショコラ
    ショコラブラウニー
    ショコラマフィン
    ショコラクッキー
    ショコラタルト
    ショコラロールケーキ
    ショコラプリン
    キャラメルプディング
    キャラメルカスタード
    キャラメルケーキ
    キャラメルブリュレ
    キャラメルエクレア
    キャラメルマカロン
    キャラメルフォンデュ
    キャラメルポップコーン
    キャラメルアーモンド
    レモンケーキ
    レモンタルト
    レモンパイ
    レモンバー
    レモンブレッド
    レモンマフィン
    レモンクッキー
    レモンスコーン
    レモンシャーベット
    レモネード
    ストロベリーショートケーキ
    ストロベリーパイ
    ストロベリータルト
    ストロベリーロールケーキ
    ストロベリームース
    ストロベリーパフェ
    ストロベリーミルクレープ
    ストロベリーシェイク
    ストロベリーマフィン
    ストロベリーフレンチトースト
    バナナケーキ
    バナナマフィン
    バナナクッキー
    バナナロールケーキ
    バナナタルト
    バナナプリン
    バナナスプリット
    バナナシェイク
    バナナフォンデュ
    マロンムース
    マロンプリン
    マロンタルト
    マロンロールケーキ
    マロンエクレア
    マロンパイ
    マロンブリュレ
    マロンフォンデュ
    マロンパフェ
    マロンモンブラン
    抹茶ティラミス
    抹茶ロールケーキ
    抹茶パンナコッタ
    抹茶プリン
    抹茶ムース
    抹茶ブラウニー
    抹茶フィナンシェ
    抹茶マカロン
    抹茶モンブラン
    抹茶パフェ
    ラムレーズンケーキ
    ラムレーズンクッキー
    ラムレーズンパウンドケーキ
    ラムレーズンスコーン
    ラムレーズンブレッド
    ラムレーズンパイ
    ラムレーズンタルト
    ラムレーズンプリン
    ラムレーズンパフェ
    ラムレーズンフォンデュ
    コーヒーケーキ
    コーヒーマフィン
    コーヒークッキー
    コーヒープリン
    コーヒーゼリー
    コーヒーアイスクリーム
    コーヒーパフェ
    コーヒーフォンデュ
    コーヒーシェイク
    コーヒーフラッペ
    ザバイオーネ
    セミフレッド
    クレームカタラーナ
    クレームダンジュ
    クレームパティシエール
    クレームモュセリーヌ
    クレームルノワーズ
    クレームシャンティー
    フォンダンバナーヌ
    バナナフリッター
    バナナクレープ
    バナナブレッド・パディング
    バナナホイップ
    バナナムース
    バナナシュークリーム
    バナナエクレア
    バナナシフォンケーキ
    マンゴームース
    マンゴータルト
    マンゴーシャーベット
    マンゴーパフェ
    マンゴーシフォンケーキ
    マンゴーロールケーキ
    マンゴーブレッド
    マンゴーマフィン
    マンゴーフラペチーノ
    パッションフルーツムース
    パッションフルーツタルト
    パッションフルーツシャーベット
    パッションフルーツロールケーキ
    パッションフルーツパフェ
    パッションフルーツゼリー
    パッションフルーツプリン
    パッションフルーツマカロン
    パッションフルーツフォンデュ
    パッションフルーツフラペチーノ
    ライチムース
    ライチゼリー
    ライチシャーベット
    ライチタルト
    ライチロールケーキ
    ライチプリン
    ライチパフェ
    ライチフラペチーノ
    ライチフォンデュ
    ライチマカロン
    ドラゴンフルーツタルト
    ドラゴンフルーツムース
    ドラゴンフルーツシャーベット
    ドラゴンフルーツプリン
    ドラゴンフルーツゼリー
    ドラゴンフルーツロールケーキ
    ドラゴンフルーツパフェ
    ドラゴンフルーツフォンデュ
    ドラゴンフルーツフラペチーノ
    ドラゴンフルーツマカロン
    スターフルーツタルト
    スターフルーツムース
    スターフルーツシャーベット
    スターフルーツプリン
    スターフルーツゼリー
    スターフルーツロールケーキ
    スターフルーツパフェ
    スターフルーツフォンデュ
    スターフルーツフラペチーノ
    スターフルーツマカロン
    ランブータンムース
    ランブータンタルト
    ランブータンシャーベット
    ランブータンプリン
    ランブータンゼリー
    ランブータンロールケーキ
    ランブータンパフェ
    ランブータンフォンデュ
    ランブータンフラペチーノ
    ランブータンマカロン
    チェリモヤタルト
    チェリモヤムース
    チェリモヤシャーベット
    チェリモヤプリン
    チェリモヤゼリー
    チェリモヤロールケーキ
    チェリモヤパフェ
    チェリモヤフォンデュ
    チェリモヤフラペチーノ
    チェリモヤマカロン
    フェイジョアタルト
    フェイジョアムース
    フェイジョアシャーベット
    フェイジョアプリン
    フェイジョアゼリー
    フェイジョアロールケーキ
    フェイジョアパフェ
    フェイジョアフォンデュ
    フェイジョアフラペチーノ
    フェイジョアマカロン
    ケーパーベリータルト
    ケーパーベリームース
    ケーパーベリーシャーベット
    ケーパーベリープリン
    ケーパーベリーゼリー
    ケーパーベリーロールケーキ
    ケーパーベリーパフェ
    ケーパーベリーフォンデュ
    ケーパーベリーフラペチーノ
    ケーパーベリーマカロン

コードに関して

  • 執筆時点では、東京リージョン未サポートのClaude3 Sonnetを利用するため、us-east-1を指定しています。
  • returnでは以下の情報をConnectフローに返します。
    • result:商品名と数量の特定結果。success or failure
    • message:特定した商品名とその数量をメッセージとして利用する
    • item1:商品名と数量
    • item2:(あれば)商品名と数量
    • item3:(あれば)商品名と数量
    • item4:(あれば)商品名と数量
    • item5:(あれば)商品名と数量

returnでの返り値について、例えば「バナナケーキを3つください。パッションフルーツゼリーも3つください。」と発話すると、以下が返り値になります。

{
  "result": "success",
  "message": "バナナケーキを、数量、3、パッションフルーツゼリーを、数量、3",
  "item1": {
    "product_name": "バナナケーキ",
    "quantity": 3
  },
  "item2": {
    "product_name": "パッションフルーツゼリー",
    "quantity": 3
  }
}

ちなみに、今回は商品名マスタとの突合のみですが、自動発注の場合は商品マスタから商品を特定後、対象の商品名の単価で注文の合計の支払い金額を伝えたり、対象の商品名の商品番号をもとに発注用APIにリクエストするなどの実装が必要になります。

Connectフロー

Connectフローは以下の通りです。

コンタクトフローを以下に貼っておきます。

コード (クリックすると展開します)
{
  "Version": "2019-10-30",
  "StartAction": "8630447c-510e-47c9-b2aa-b2f3aa2452f9",
  "Metadata": {
    "entryPointPosition": {
      "x": -17.6,
      "y": 52
    },
    "ActionMetadata": {
      "8630447c-510e-47c9-b2aa-b2f3aa2452f9": {
        "position": {
          "x": 60,
          "y": 38.4
        }
      },
      "d1aa2cd0-2c59-4321-a479-c83670e51601": {
        "position": {
          "x": 477.6,
          "y": 209.6
        }
      },
      "69b3180b-f876-488f-998f-723d37f28153": {
        "position": {
          "x": 44.8,
          "y": 217.6
        },
        "toCustomer": false,
        "fromCustomer": true
      },
      "6fd071f1-63e5-4bdf-bc61-afa46555ff47": {
        "position": {
          "x": 256,
          "y": 208.8
        },
        "parameters": {
          "LexV2Bot": {
            "AliasArn": {
              "displayName": "TestBotAlias",
              "useLexBotDropdown": true,
              "lexV2BotName": "cm-hirai-one-voice"
            }
          }
        },
        "dynamicMetadata": {
          "x-amz-lex:audio:start-timeout-ms:*:*": false,
          "x-amz-lex:audio:end-timeout-ms:*:*": false,
          "x-amz-lex:audio:max-length-ms:*:*": false
        },
        "useLexBotDropdown": true,
        "lexV2BotName": "cm-hirai-one-voice",
        "lexV2BotAliasName": "TestBotAlias",
        "conditionMetadata": [
          {
            "id": "3e47b03b-a144-48f7-a6dd-fb91f6d03857",
            "operator": {
              "name": "Equals",
              "value": "Equals",
              "shortDisplay": "="
            },
            "value": "OneVoice"
          }
        ]
      },
      "5bf35837-3aad-4374-91f1-9ca0c27c5afc": {
        "position": {
          "x": 40,
          "y": 452.8
        }
      },
      "49007031-591c-41e6-a446-ad9ac799e081": {
        "position": {
          "x": 254.4,
          "y": 448.8
        },
        "parameters": {
          "LambdaFunctionARN": {
            "displayName": "cm-hirai-whisper-product-name"
          }
        },
        "dynamicMetadata": {}
      },
      "6b04c75b-44fb-467b-a000-14261649124f": {
        "position": {
          "x": 473.6,
          "y": 747.2
        }
      },
      "2e6627fa-f5fc-4f6c-b018-72d6f3624689": {
        "position": {
          "x": 1400,
          "y": 749.6
        }
      },
      "6b6a5984-8096-4946-987b-54537088a266": {
        "position": {
          "x": 264,
          "y": 28.8
        },
        "children": [
          "bdd32c03-51fd-4bf6-ad67-49476a22ae40"
        ],
        "overrideConsoleVoice": true,
        "fragments": {
          "SetContactData": "bdd32c03-51fd-4bf6-ad67-49476a22ae40"
        },
        "overrideLanguageAttribute": true
      },
      "bdd32c03-51fd-4bf6-ad67-49476a22ae40": {
        "position": {
          "x": 264,
          "y": 28.8
        },
        "dynamicParams": []
      },
      "17b3d6aa-cb41-4601-b8fd-47a19c08dce2": {
        "position": {
          "x": 658.4,
          "y": 0
        },
        "parameters": {
          "PromptId": {
            "displayName": "Beep.wav"
          }
        },
        "promptName": "Beep.wav"
      },
      "ace21dc3-9b85-41e2-a0eb-32aaea2c0085": {
        "position": {
          "x": 460.8,
          "y": 8
        }
      },
      "f57d84b5-a511-4214-a271-8761733a8553": {
        "position": {
          "x": 688,
          "y": 449.6
        },
        "parameters": {
          "Attributes": {
            "message": {
              "useDynamic": true
            },
            "item1": {
              "useDynamic": true
            },
            "item2": {
              "useDynamic": true
            },
            "item3": {
              "useDynamic": true
            },
            "item4": {
              "useDynamic": true
            },
            "item5": {
              "useDynamic": true
            }
          }
        },
        "dynamicParams": [
          "message",
          "item1",
          "item2",
          "item3",
          "item4",
          "item5"
        ]
      },
      "ddc0145a-ac98-445b-bb5a-b1aad3dc1441": {
        "position": {
          "x": 1156,
          "y": 656.8
        }
      },
      "a65b7aed-cf7d-4a4c-b468-42350f2ae79d": {
        "position": {
          "x": 898.4,
          "y": 452
        }
      },
      "a9643ec3-3551-4413-9a6d-5fc1ff04ddaf": {
        "position": {
          "x": 473.6,
          "y": 447.2
        },
        "conditions": [],
        "conditionMetadata": [
          {
            "id": "da97dc15-4cfe-4b3d-887b-4e972f0875a9",
            "operator": {
              "name": "Equals",
              "value": "Equals",
              "shortDisplay": "="
            },
            "value": "success"
          }
        ]
      },
      "358abe17-1a67-44f7-9f26-7f3c5c8d8cfb": {
        "position": {
          "x": 1145.6,
          "y": 454.4
        }
      },
      "b4baf4fd-33cc-4ede-b30b-4a81e854bce6": {
        "position": {
          "x": 684,
          "y": 661.6
        }
      }
    },
    "Annotations": [],
    "name": "cm-hirai-bedrock-whisper-product-name",
    "description": "",
    "type": "contactFlow",
    "status": "published",
    "hash": {}
  },
  "Actions": [
    {
      "Parameters": {
        "FlowLoggingBehavior": "Enabled"
      },
      "Identifier": "8630447c-510e-47c9-b2aa-b2f3aa2452f9",
      "Type": "UpdateFlowLoggingBehavior",
      "Transitions": {
        "NextAction": "6b6a5984-8096-4946-987b-54537088a266"
      }
    },
    {
      "Parameters": {
        "MediaStreamingState": "Disabled",
        "Participants": [
          {
            "ParticipantType": "Customer",
            "MediaDirections": [
              "To",
              "From"
            ]
          }
        ],
        "MediaStreamType": "Audio"
      },
      "Identifier": "d1aa2cd0-2c59-4321-a479-c83670e51601",
      "Type": "UpdateContactMediaStreamingBehavior",
      "Transitions": {
        "NextAction": "5bf35837-3aad-4374-91f1-9ca0c27c5afc",
        "Errors": [
          {
            "NextAction": "5bf35837-3aad-4374-91f1-9ca0c27c5afc",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "MediaStreamingState": "Enabled",
        "MediaStreamType": "Audio",
        "Participants": [
          {
            "ParticipantType": "Customer",
            "MediaDirections": [
              "From"
            ]
          }
        ]
      },
      "Identifier": "69b3180b-f876-488f-998f-723d37f28153",
      "Type": "UpdateContactMediaStreamingBehavior",
      "Transitions": {
        "NextAction": "6fd071f1-63e5-4bdf-bc61-afa46555ff47",
        "Errors": [
          {
            "NextAction": "6fd071f1-63e5-4bdf-bc61-afa46555ff47",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": ",",
        "LexV2Bot": {
          "AliasArn": "arn:aws:lex:ap-northeast-1:xxxxxxxxxxxx:bot-alias/TM0D9GHJ7K/TSTALIASID"
        },
        "LexSessionAttributes": {
          "x-amz-lex:audio:start-timeout-ms:*:*": "15000",
          "x-amz-lex:audio:end-timeout-ms:*:*": "1000",
          "x-amz-lex:audio:max-length-ms:*:*": "15000"
        }
      },
      "Identifier": "6fd071f1-63e5-4bdf-bc61-afa46555ff47",
      "Type": "ConnectParticipantWithLexBot",
      "Transitions": {
        "NextAction": "d1aa2cd0-2c59-4321-a479-c83670e51601",
        "Conditions": [
          {
            "NextAction": "d1aa2cd0-2c59-4321-a479-c83670e51601",
            "Condition": {
              "Operator": "Equals",
              "Operands": [
                "OneVoice"
              ]
            }
          }
        ],
        "Errors": [
          {
            "NextAction": "d1aa2cd0-2c59-4321-a479-c83670e51601",
            "ErrorType": "NoMatchingCondition"
          },
          {
            "NextAction": "d1aa2cd0-2c59-4321-a479-c83670e51601",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "メッセージをお預かりしました。"
      },
      "Identifier": "5bf35837-3aad-4374-91f1-9ca0c27c5afc",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "49007031-591c-41e6-a446-ad9ac799e081",
        "Errors": [
          {
            "NextAction": "49007031-591c-41e6-a446-ad9ac799e081",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "LambdaFunctionARN": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:cm-hirai-whisper-product-name",
        "InvocationTimeLimitSeconds": "8",
        "ResponseValidation": {
          "ResponseType": "JSON"
        }
      },
      "Identifier": "49007031-591c-41e6-a446-ad9ac799e081",
      "Type": "InvokeLambdaFunction",
      "Transitions": {
        "NextAction": "a9643ec3-3551-4413-9a6d-5fc1ff04ddaf",
        "Errors": [
          {
            "NextAction": "6b04c75b-44fb-467b-a000-14261649124f",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "エラーとなりました。電話をきります。"
      },
      "Identifier": "6b04c75b-44fb-467b-a000-14261649124f",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "2e6627fa-f5fc-4f6c-b018-72d6f3624689",
        "Errors": [
          {
            "NextAction": "2e6627fa-f5fc-4f6c-b018-72d6f3624689",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {},
      "Identifier": "2e6627fa-f5fc-4f6c-b018-72d6f3624689",
      "Type": "DisconnectParticipant",
      "Transitions": {}
    },
    {
      "Parameters": {
        "TextToSpeechEngine": "Neural",
        "TextToSpeechStyle": "None",
        "TextToSpeechVoice": "Kazuha"
      },
      "Identifier": "6b6a5984-8096-4946-987b-54537088a266",
      "Type": "UpdateContactTextToSpeechVoice",
      "Transitions": {
        "NextAction": "bdd32c03-51fd-4bf6-ad67-49476a22ae40"
      }
    },
    {
      "Parameters": {
        "LanguageCode": "ja-JP"
      },
      "Identifier": "bdd32c03-51fd-4bf6-ad67-49476a22ae40",
      "Type": "UpdateContactData",
      "Transitions": {
        "NextAction": "ace21dc3-9b85-41e2-a0eb-32aaea2c0085",
        "Errors": [
          {
            "NextAction": "ace21dc3-9b85-41e2-a0eb-32aaea2c0085",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "PromptId": "arn:aws:connect:ap-northeast-1:xxxxxxxxxxxx:instance/3ff2093d-af96-43fd-b038-3c07cdd7609c/prompt/54bb3277-0484-45eb-bdc7-2e0b1af31b5c"
      },
      "Identifier": "17b3d6aa-cb41-4601-b8fd-47a19c08dce2",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "69b3180b-f876-488f-998f-723d37f28153",
        "Errors": [
          {
            "NextAction": "69b3180b-f876-488f-998f-723d37f28153",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "商品名と数量をお伝え下さい。"
      },
      "Identifier": "ace21dc3-9b85-41e2-a0eb-32aaea2c0085",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "17b3d6aa-cb41-4601-b8fd-47a19c08dce2",
        "Errors": [
          {
            "NextAction": "17b3d6aa-cb41-4601-b8fd-47a19c08dce2",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Attributes": {
          "message": "$.External.message",
          "item1": "$.External.item1",
          "item2": "$.External.item2",
          "item3": "$.External.item3",
          "item4": "$.External.item4",
          "item5": "$.External.item5"
        },
        "TargetContact": "Current"
      },
      "Identifier": "f57d84b5-a511-4214-a271-8761733a8553",
      "Type": "UpdateContactAttributes",
      "Transitions": {
        "NextAction": "a65b7aed-cf7d-4a4c-b468-42350f2ae79d",
        "Errors": [
          {
            "NextAction": "a65b7aed-cf7d-4a4c-b468-42350f2ae79d",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "電話をきります。"
      },
      "Identifier": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "2e6627fa-f5fc-4f6c-b018-72d6f3624689",
        "Errors": [
          {
            "NextAction": "2e6627fa-f5fc-4f6c-b018-72d6f3624689",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "ご注文の内容は、$.Attributes.message 、ですね。お間違いないでしょうか\n"
      },
      "Identifier": "a65b7aed-cf7d-4a4c-b468-42350f2ae79d",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "358abe17-1a67-44f7-9f26-7f3c5c8d8cfb",
        "Errors": [
          {
            "NextAction": "358abe17-1a67-44f7-9f26-7f3c5c8d8cfb",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "ComparisonValue": "$.External.result"
      },
      "Identifier": "a9643ec3-3551-4413-9a6d-5fc1ff04ddaf",
      "Type": "Compare",
      "Transitions": {
        "NextAction": "b4baf4fd-33cc-4ede-b30b-4a81e854bce6",
        "Conditions": [
          {
            "NextAction": "f57d84b5-a511-4214-a271-8761733a8553",
            "Condition": {
              "Operator": "Equals",
              "Operands": [
                "success"
              ]
            }
          }
        ],
        "Errors": [
          {
            "NextAction": "b4baf4fd-33cc-4ede-b30b-4a81e854bce6",
            "ErrorType": "NoMatchingCondition"
          }
        ]
      }
    },
    {
      "Parameters": {
        "LoopCount": "10"
      },
      "Identifier": "358abe17-1a67-44f7-9f26-7f3c5c8d8cfb",
      "Type": "Loop",
      "Transitions": {
        "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
        "Conditions": [
          {
            "NextAction": "ace21dc3-9b85-41e2-a0eb-32aaea2c0085",
            "Condition": {
              "Operator": "Equals",
              "Operands": [
                "ContinueLooping"
              ]
            }
          },
          {
            "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
            "Condition": {
              "Operator": "Equals",
              "Operands": [
                "DoneLooping"
              ]
            }
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "商品名と数量を聞き取ることができませんでした。"
      },
      "Identifier": "b4baf4fd-33cc-4ede-b30b-4a81e854bce6",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "358abe17-1a67-44f7-9f26-7f3c5c8d8cfb",
        "Errors": [
          {
            "NextAction": "358abe17-1a67-44f7-9f26-7f3c5c8d8cfb",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    }
  ]
}

テストのため、ループブロックを利用しています。

発話終了時に録音停止

発話終了のタイミングで、「メディアストリーミングの停止」ブロックに進めるために、Amazon Lexを利用しています。

以下の記事で詳細に解説していますので、ご参照ください。

ヒアリングした商品名と数量の音声出力

メディアストリーミングを停止し、Lambda呼び出し後、「コンタクト属性を確認する」ブロックで商品名と数量を特定できたかresultで判定します。

音声出力では、「コンタクト属性の設定」ブロックでmessageを定義し、次のブロックである「プロンプト再生」で音声出力します。自動で発注など、後の処理での利用を想定して、item1item2item3item4item5も定義しています。

できればitemを1つの配列でまとめたいのですが、Connectフローは配列の参照をサポートされていません。

配列の参照はフローではサポートされていません。配列は別の Lambda 関数でのみ使用できます。引用

ご注文の内容は、$.Attributes.message 、ですね。お間違いないでしょうか

テスト結果

テスト結果の前にテスト内容を紹介します。

電話をかけた後、例えば、「バナナケーキを3つください。パッションフルーツゼリーも3つください。」と発話すると、LambdaがConnectフローに以下を値を返しつつ、ログに残ります。返り値を確認し判定をします。

{
  "result": "success",
  "message": "バナナケーキを、数量、3、パッションフルーツゼリーを、数量、3",
  "item1": {
    "product_name": "バナナケーキ",
    "quantity": 3
  },
  "item2": {
    "product_name": "パッションフルーツゼリー",
    "quantity": 3
  }
}

商品名が特定できない場合、failureで返されます。

{
  "result": "failure"
}

数量を伝えない場合、数量は1にするようにプロンプトを設定しています。

結果は以下の通りです。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
トリュフショコラください 鳥風ショコラください。 トリュフショコラを、数量、1 OK
トリュフショコラください トリフ・ショコラください。 トリュフショコラを、数量、1 OK
ゼリーをください ゼリーをください。 ゼリーを、数量、1 OK
パフェをください。3つです。 パフェをください。3つです。 パフェを、数量、3 OK
コーヒーゼリーとマロンパイをください コーヒーゼリーとマロンパイをください。 コーヒーゼリーを、数量、1、マロンパイを、数量、1 OK
チョコレート3個とチョコレートバー100本ください チョコレート3個とチョコレートバー100本ください。 チョコレートを、数量、3、チョコレートバーを、数量、100 OK
ポップコーンとパンケーキをそれぞれ2つずつください ポップコーンとパンケーキをそれぞれ2つずつください。 ポップコーンを、数量、2、パンケーキを、数量、2 OK
バナナケーキを3つください。パッションフルーツゼリーも同じ数ください バナナケーキを3つください。パッションフルーツゼリーも同じ数ください。 バナナケーキを、数量、3、パッションフルーツゼリーを、数量、3 OK
いなり寿司ください。あと、ぎょうざをください 稲荷寿司をください。あと、餃子をください。 いなり寿司を、数量、1、ギョーザを、数量、1 OK
オレンジジュースを2本ください オレンジジュースを2本ください。 {"result": "failure"} OK
豚骨ラーメン3杯ください とんこつラーメンを3杯ください。 {"result": "failure"} OK
伊達巻4つとチーズワッフル2個とカスタードタルト12個ください 縦巻き4つと、チーズワッフル2個と、カスタードタルト12個ください。 ロールケーキを、数量、4、チーズワッフルを、数量、2、カスタードタルトを、数量、12 NG
伊達巻4つとチーズワッフル2個とカスタードタルト12個ください だて巻き4つとチーズワッフル2個とカスタードタルト12個ください。 伊達巻を、数量、4、チーズワッフルを、数量、2、カスタードタルトを、数量、12 OK
抹茶ようかんをください 抹茶洋館をください。 抹茶モンブランを、数量、1 NG
小籠包をください 小論報をください。 小倉羊羹を、数量、1 NG
小籠包をください 小論法をください。 小倉羊羹を、数量、1 NG
焼売をください 終売をください。 {"result": "failure"} NG
マリトッツォ3つとラングドシャ5つください。やっぱりマリトッツォはなしで マリトッツを三つとラングドシャ 五つください やっぱりマリトッツ はなしで ラングドシャを、数量、5 OK
マリトッツォ3つとラングドシャ5つください。やっぱりマリトッツォはキャンセルです マリトッツ三つとラングド社五 つください やっぱりマリトッツ はキャンセルです ラングドシャを、数量、5 OK
ラスク3つとワッフル5つください。やっぱりワッフルは3つでお願いします。 ラスク3つとワッフル5つください やっぱりワッフルは3つお願いします ラスクを、数量、3、ワッフルを、数量、3 OK
ライチムース1つとマンゴーシャーベット2つください。やっぱり、ライチムースではなくきなこもちを4つにしてください ライチムース1つとマンゴーシャーベット2つください。やっぱりライチムースではなくきなこ餅を4つにしてください。 きなこもちを、数量、4、マンゴーシャーベットを、数量、2 OK
梅干しをください。やっぱり栗きんとんを3つにしてください 梅干しをください。やっぱり栗きんとんを3つにしてください。 栗きんとんを、数量、3 OK
小豆の入った大福ください あずきの入った大福ください" 小豆大福を、数量、1 OK
ケーキをください。味はラムレーズンです ケーキをください。味はラムレーズンです。 ラムレーズンケーキを、数量、1 OK
バナナ味のケーキをください バナナ味のケーキをください。 バナナケーキを、数量、1 OK
きんとんを1つください きんとんをひとつください。 栗きんとんを、数量、1 OK
麦パンください
(ライ麦パンの「ライ」を省略)
麦パンください 食パン NG
ライ麦パンください ライムギパンください ライ麦パンを、数量、1 OK

ちなみに、発話後、復唱の音声出力までに8~9秒ほどかかりました。

発話内容に応じて、以下の4つのカテゴリに分類してコメントします。

  • 商品マスタに載っていない商品
  • Whisperでの不適切な文字起こし
  • 注文の変更
  • 回りくどい言い方

商品マスタに載っていない商品

商品マスタに載っていない商品は、failureと判定されています。正しい挙動です。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
オレンジジュースを2本ください オレンジジュースを2本ください。 {"result": "failure"} OK
豚骨ラーメン3杯ください とんこつラーメンを3杯ください。 {"result": "failure"} OK

Whisperでの不適切な文字起こし

Whisperでの文字起こし結果が不適切な場合、Claudeが商品マスタの商品名を正しく特定できない可能性があります。

伊達巻の発話を文字起こしする場合、「伊達巻」だけでなく「縦巻き」と文字起こしされることがありました。「縦巻き」の場合、商品マスタのロールケーキであるとClaudeで認識されました。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
伊達巻4つとチーズワッフル2個とカスタードタルト12個ください 縦巻き4つと、チーズワッフル2個と、カスタードタルト12個ください。 ロールケーキを、数量、4、チーズワッフルを、数量、2、カスタードタルトを、数量、12 NG
伊達巻4つとチーズワッフル2個とカスタードタルト12個ください だて巻き4つとチーズワッフル2個とカスタードタルト12個ください。 伊達巻を、数量、4、チーズワッフルを、数量、2、カスタードタルトを、数量、12 OK

また、小籠包や焼売は、何度試しても文字起こし精度が要因で、誤認識されました。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
小籠包をください 小論報をください。 小倉羊羹を、数量、1 NG
小籠包をください 小論法をください。 小倉羊羹を、数量、1 NG
焼売をください 終売をください。 {"result": "failure"} NG
抹茶ようかんをください 抹茶洋館をください。 抹茶モンブランを、数量、1 NG

注文の変更

注文途中で、商品や数量の変更については、正しく認識されました。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
マリトッツォ3つとラングドシャ5つください。やっぱりマリトッツォはなしで マリトッツを三つとラングドシャ 五つください やっぱりマリトッツ はなしで ラングドシャを、数量、5 OK
マリトッツォ3つとラングドシャ5つください。やっぱりマリトッツォはキャンセルです マリトッツ三つとラングド社五 つください やっぱりマリトッツ はキャンセルです ラングドシャを、数量、5 OK
ラスク3つとワッフル5つください。やっぱりワッフルは3つでお願いします。 ラスク3つとワッフル5つください やっぱりワッフルは3つお願いします ラスクを、数量、3、ワッフルを、数量、3 OK
ライチムース1つとマンゴーシャーベット2つください。やっぱり、ライチムースではなくきなこもちを4つにしてください ライチムース1つとマンゴーシャーベット2つください。やっぱりライチムースではなくきなこ餅を4つにしてください。 きなこもちを、数量、4、マンゴーシャーベットを、数量、2 OK
梅干しをください。やっぱり栗きんとんを3つにしてください 梅干しをください。やっぱり栗きんとんを3つにしてください。 栗きんとんを、数量、3 OK

回りくどい言い方

小豆大福を「小豆の入った大福」などの回りくどい伝え方でも、正しく認識してくれていました。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
小豆の入った大福ください あずきの入った大福ください" 小豆大福を、数量、1 OK
ケーキをください。味はラムレーズンです ケーキをください。味はラムレーズンです。 ラムレーズンケーキを、数量、1 OK
バナナ味のケーキをください バナナ味のケーキをください。 バナナケーキを、数量、1 OK

栗きんとんを「きんとん」と省略しても、認識してくれています。ただし、商品によります。ライ麦パンを「麦パン」と省略すると、誤認識しました。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
きんとんを1つください きんとんをひとつください。 栗きんとんを、数量、1 OK
麦パンください
(ライ麦パンの「ライ」を省略)
麦パンください 食パン NG
ライ麦パンください ライムギパンください ライ麦パンを、数量、1 OK

生成AIモデル

今回は、Claude 3 Sonnetを採用しています。複数のモデルを試しましたが、最も精度がよかったためです。本記事投稿日にOpusが利用できるようなったため後日、試してみます。

追記:Opusの内容を加えます。

レスポンス時間については、以下にまとめました。今回のプロンプトでは、入力トークンが約6700トークンで、出力トークンが約100トークンです。

モデル レイテンシー(ms) 精度
Claude 3 Opus
オレゴンリージョン
7~8秒 〇〇〇
Claude 3 Sonnet
バージニアリージョン
4~6秒 〇〇
Claude 3 Haiku
バージニアリージョン
2秒
Claude 2.1
東京リージョン
8~10秒

Claude 3が東京リージョンでも利用できるようになれば、レイテンシーは下がる可能性が高いです。

上記のテストをOpusで試すと、以下の抹茶ようかんのみNG→OKになりました。

発話内容 Whisperでの文字起こし Claudeで商品と数量の認識
(message)
OK/NG
抹茶ようかんをください 抹茶洋館をください。 抹茶ようかんを、数量、1 OK

認識精度向上の方法

認識精度向上について考えてみます。

1点目は、Claudeのプロンプトの調整です。有効なプロンプトによって文字起こし精度を上げることができる可能性があります。

2点目は、利用する文字起こしサービスの変更です。例えば、Amazon Transcribeを利用し、カスタム語彙に商品名を全て設定することで、名詞のヒアリング精度が向上する可能性があります。Whisper APIのプロンプトでも利用はできますが、わずか224トークンという制限があるため、実用的ではありません。

最後に

Amazon Connect、Amazon Bedrock、Whisper APIを組み合わせて、電話で発話された注文内容(商品名と数量)を認識し、商品マスタと突合する方法とその精度を検証しました。

テスト結果は、雑音がする場所や発話するイントネーションなどによっては精度が変わる可能性が大いにありますので、導入する前に精度検証は必須です。

電話の無人対応での自動注文や注文内容のヒアリング精度が気になる方へ参考になれば幸いです。