[電話無人対応] Amazon Bedrock + Whisperで住所のヒアリング精度を確認してみた[Amazon Connect]

2024.04.09

はじめに

Amazon Connect + Amazon Bedrock + Whisper APIの組み合わせで、電話での発話による住所のヒアリング精度を確認してみました。

ヒアリング精度の確認方法は、発話によって住所を伝えた際、発話通り住所を認識するかAWS Lambdaのログから確認します。

利用シーンとしては、留守番電話やAIチャットボットでの一次対応で住所をヒアリングし、後から人が確認することを想定しています。

ヒアリングした住所をDBと突き合わせるのは、住所の表記揺れ等で難しいため想定していません。

構成

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

住所のヒアリングに関して、Connectのフローは下記の通りです。

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

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

もし、住所を認識できない場合、以下の通り、担当者に変える流れになります。今回紹介するフローでは音声出力のみで、実際には変えません。

前提

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

構築

AWS Lambda

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

  1. LambdaでKVSからメディアデータを取得します。
  2. メディアデータから音声データを抽出し、WAV形式に変換後、Lambdaのローカルに保存します。
  3. Whisper APIで音声ファイルに対して、文字起こしを実行します。
  4. Amazon 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秒から10秒に変更
  • メモリは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"]

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):
    system_prompt = "A Japanese address will be spoken aloud."
    with open(file_path, "rb") as audio_file:
        return openai.Audio.transcribe("whisper-1", audio_file, prompt=system_prompt)

def extract_json_format(input_text):
    bedrock_runtime = boto3.client('bedrock-runtime', region_name="us-east-1")
    prompt = f"""\n\nHuman:お客さんのお問い合わせから、ルールに遵守して住所を抽出し、JSON形式で出力してください。
    <rule>
        - 推測される住所の部分のみを返してください
        - 住所は、都道府県名、市区町村、丁目、番地、号、建物名、階数や部屋番号を含めてください。
          - 丁目、番地、号、建物名、階数は、sentenceに存在しない場合、付け加えなくてよいです。
          - 丁目、番地、号は、ハイフンで区切って下さい。例:1-1-1
            - 番地で終わる住所は、「番地」という言葉を残してください
            - 部屋番号は、「号室」と伝えられない可能性があります。「号室」を追加して下さい。例:日比谷フォートタワー101 → 日比谷フォートタワー101号室
          - 建物名の前は、半角スペースを開けて下さい。例:1-1-1 日比谷フォートタワー26階
          - 住所に使用する数字は、全てアラビア数字(1, 2, 3, ...)を使用してください。
        - sentenceの住所の地名は間違いの可能性があります。実際に日本に存在する地名に変換してください。
        - 住所を読み取れない場合、result値はfailureにしてください。読み取れた場合、result値はsuccessにしてください。
        - JSON形式以外は出力しないでください。
        - addressから以下のルールに沿って、audio_outputを出力ください。
            - 都道府県や市、区、町丁、村の間にカンマを入れて下さい。
            - ハイフン(-)の箇所だけ、「の」に置き換えてください。
            - 建物名の前にカンマを入れて下さい。
            - 建物名と階数の間にカンマを入れて下さい。
    </rule>
    ## 例1
    {{
      "address": "東京都港区西新橋1-1-1 日比谷フォートタワー16階",
      "audio_output": "東京都、港区、西新橋、1の1の1、日比谷フォートタワー、16階"
      "result": "success",
    }}
    ## 例2
    {{
      "address": "東京都港区西新橋1-1-1 日比谷フォートタワー101号室",
      "audio_output": "東京都、港区、西新橋、1の1の1、日比谷フォートタワー、101号室"
      "result": "success",
    }}
    ## 例3
    {{
      "address": "東京都港区西新橋1111番地",
      "audio_output": "東京都、港区、西新橋、1111番地"
      "result": "success",
    }}
    ## 例4
    {{
      "address": "東京都港区西新橋1111番地2号",
      "audio_output": "東京都、港区、西新橋、1111の2"
      "result": "success",
    }}
    <sentence>
        {input_text}
    </sentence>
    Assistant:{{
    """

    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())
    response_text = response_body["content"][0]["text"]
    json_data = json.loads(response_text)
    return json_data

def lambda_handler(event, context):
    print('Received 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")
    transcript = str(transcript["text"])
    print("Transcript result:" + transcript)
    
    address_data = extract_json_format(transcript)
    response_data = {
        "transcript": transcript,
        "address": address_data["address"],
        "audio_output": address_data["audio_output"],
        "result": address_data["result"]
    }
    print("Result:", json.dumps(response_data, ensure_ascii=False))

    return response_data
  • 執筆時点では、東京リージョン未サポートのClaude3を利用するため、us-east-1を指定しています。
  • returnでは以下の情報をConnectフローに返します。
    • transcript:Whisper APIでの文字起こし内容
    • addresstranscriptから住所を抽出 + 整形
    • audio_outputaddressを発音用に整形
    • result:住所の抽出結果。success or failure
  • プロンプトは、住所を抽出して整形した住所と、整形した住所から発音用の住所に整形するように設定しています。

Whisper APIには、わずかに精度向上がみられたため、以下の通り住所の文字起こしであることをプロンプトに設定しています。

system_prompt = "A Japanese address will be spoken aloud."

Claudeでのプロンプトでは、例を4つ上げています。理由は、例を上げると精度がよくなる傾向がみられためであり、特殊な住所(丁目がない、番地で終わる、号で終わるなど)の場合、例を増やすとよい印象です。

Connectフロー

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

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

コード (クリックすると展開します)
{
  "Version": "2019-10-30",
  "StartAction": "8630447c-510e-47c9-b2aa-b2f3aa2452f9",
  "Metadata": {
    "entryPointPosition": { "x": -17.6, "y": 52 },
    "ActionMetadata": {
      "6b6a5984-8096-4946-987b-54537088a266": {
        "position": { "x": 264, "y": 28.8 },
        "children": ["07621c39-f91b-49c2-951e-e097c0783179"],
        "overrideConsoleVoice": true,
        "fragments": {
          "SetContactData": "07621c39-f91b-49c2-951e-e097c0783179"
        },
        "overrideLanguageAttribute": true
      },
      "07621c39-f91b-49c2-951e-e097c0783179": {
        "position": { "x": 264, "y": 28.8 },
        "dynamicParams": []
      },
      "8630447c-510e-47c9-b2aa-b2f3aa2452f9": {
        "position": { "x": 60, "y": 38.4 }
      },
      "2e6627fa-f5fc-4f6c-b018-72d6f3624689": {
        "position": { "x": 1346.4, "y": 740.8 }
      },
      "d1aa2cd0-2c59-4321-a479-c83670e51601": {
        "position": { "x": 477.6, "y": 209.6 }
      },
      "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": "7ee2ef6d-b136-4b83-9f30-d725d640c3af",
            "operator": {
              "name": "Equals",
              "value": "Equals",
              "shortDisplay": "="
            },
            "value": "OneVoice"
          }
        ]
      },
      "5bf35837-3aad-4374-91f1-9ca0c27c5afc": {
        "position": { "x": 39.2, "y": 452 }
      },
      "49007031-591c-41e6-a446-ad9ac799e081": {
        "position": { "x": 254.4, "y": 448.8 },
        "parameters": {
          "LambdaFunctionARN": { "displayName": "cm-hirai-whisper-address" }
        },
        "dynamicMetadata": {}
      },
      "a9643ec3-3551-4413-9a6d-5fc1ff04ddaf": {
        "position": { "x": 484, "y": 451.2 },
        "conditions": [],
        "conditionMetadata": [
          {
            "id": "e6fb22d2-44db-47c6-89a5-ab3711d7c13a",
            "operator": {
              "name": "Equals",
              "value": "Equals",
              "shortDisplay": "="
            },
            "value": "success"
          }
        ]
      },
      "f57d84b5-a511-4214-a271-8761733a8553": {
        "position": { "x": 696.8, "y": 454.4 },
        "parameters": {
          "Attributes": {
            "address": { "useDynamic": true },
            "audio_output": { "useDynamic": true }
          }
        },
        "dynamicParams": ["address", "audio_output"]
      },
      "ea1420e6-dce0-482e-96bd-c509bd3e1425": {
        "position": { "x": 912, "y": 452 }
      },
      "ddc0145a-ac98-445b-bb5a-b1aad3dc1441": {
        "position": { "x": 1124.8, "y": 671.2 }
      },
      "ace21dc3-9b85-41e2-a0eb-32aaea2c0085": {
        "position": { "x": 460.8, "y": 8 }
      },
      "69b3180b-f876-488f-998f-723d37f28153": {
        "position": { "x": 44.8, "y": 217.6 },
        "toCustomer": false,
        "fromCustomer": true
      },
      "a65b7aed-cf7d-4a4c-b468-42350f2ae79d": {
        "position": { "x": 1126.4, "y": 455.2 }
      },
      "17b3d6aa-cb41-4601-b8fd-47a19c08dce2": {
        "position": { "x": 687.2, "y": -1.6 },
        "parameters": { "PromptId": { "displayName": "Beep.wav" } },
        "promptName": "Beep.wav"
      }
    },
    "Annotations": [],
    "name": "cm-hirai-bedrock-whisper-address",
    "description": "",
    "type": "contactFlow",
    "status": "published",
    "hash": {}
  },
  "Actions": [
    {
      "Parameters": {
        "TextToSpeechEngine": "Neural",
        "TextToSpeechStyle": "None",
        "TextToSpeechVoice": "Kazuha"
      },
      "Identifier": "6b6a5984-8096-4946-987b-54537088a266",
      "Type": "UpdateContactTextToSpeechVoice",
      "Transitions": { "NextAction": "07621c39-f91b-49c2-951e-e097c0783179" }
    },
    {
      "Parameters": { "LanguageCode": "ja-JP" },
      "Identifier": "07621c39-f91b-49c2-951e-e097c0783179",
      "Type": "UpdateContactData",
      "Transitions": {
        "NextAction": "ace21dc3-9b85-41e2-a0eb-32aaea2c0085",
        "Errors": [
          {
            "NextAction": "ace21dc3-9b85-41e2-a0eb-32aaea2c0085",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": { "FlowLoggingBehavior": "Enabled" },
      "Identifier": "8630447c-510e-47c9-b2aa-b2f3aa2452f9",
      "Type": "UpdateFlowLoggingBehavior",
      "Transitions": { "NextAction": "6b6a5984-8096-4946-987b-54537088a266" }
    },
    {
      "Parameters": {},
      "Identifier": "2e6627fa-f5fc-4f6c-b018-72d6f3624689",
      "Type": "DisconnectParticipant",
      "Transitions": {}
    },
    {
      "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": {
        "Text": ",",
        "LexV2Bot": {
          "AliasArn": "arn:aws:lex:ap-northeast-1:xxxxxxxx:bot-alias/TM0D9GHJ7K/TSTALIASID"
        },
        "LexSessionAttributes": {
          "x-amz-lex:audio:start-timeout-ms:*:*": "15000",
          "x-amz-lex:audio:end-timeout-ms:*:*": "2000",
          "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:xxxxxxxx:function:cm-hirai-whisper-address",
        "InvocationTimeLimitSeconds": "8",
        "ResponseValidation": { "ResponseType": "JSON" }
      },
      "Identifier": "49007031-591c-41e6-a446-ad9ac799e081",
      "Type": "InvokeLambdaFunction",
      "Transitions": {
        "NextAction": "a9643ec3-3551-4413-9a6d-5fc1ff04ddaf",
        "Errors": [
          {
            "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": { "ComparisonValue": "$.External.result" },
      "Identifier": "a9643ec3-3551-4413-9a6d-5fc1ff04ddaf",
      "Type": "Compare",
      "Transitions": {
        "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
        "Conditions": [
          {
            "NextAction": "f57d84b5-a511-4214-a271-8761733a8553",
            "Condition": { "Operator": "Equals", "Operands": ["success"] }
          }
        ],
        "Errors": [
          {
            "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
            "ErrorType": "NoMatchingCondition"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Attributes": {
          "address": "$.External.address",
          "audio_output": "$.External.audio_output"
        },
        "TargetContact": "Current"
      },
      "Identifier": "f57d84b5-a511-4214-a271-8761733a8553",
      "Type": "UpdateContactAttributes",
      "Transitions": {
        "NextAction": "ea1420e6-dce0-482e-96bd-c509bd3e1425",
        "Errors": [
          {
            "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": { "LoopCount": "10" },
      "Identifier": "ea1420e6-dce0-482e-96bd-c509bd3e1425",
      "Type": "Loop",
      "Transitions": {
        "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
        "Conditions": [
          {
            "NextAction": "a65b7aed-cf7d-4a4c-b468-42350f2ae79d",
            "Condition": {
              "Operator": "Equals",
              "Operands": ["ContinueLooping"]
            }
          },
          {
            "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
            "Condition": { "Operator": "Equals", "Operands": ["DoneLooping"] }
          }
        ]
      }
    },
    {
      "Parameters": { "Text": "担当者に変わります。\n" },
      "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": "ご住所をお伝え下さい。" },
      "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": {
        "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": {
        "SSML": "<speak>\n    ご住所は、\n        <prosody rate=\"slow\">\n            $.Attributes.audio_output\n        </prosody>\n    、ですね。\n</speak>\n"
      },
      "Identifier": "a65b7aed-cf7d-4a4c-b468-42350f2ae79d",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "17b3d6aa-cb41-4601-b8fd-47a19c08dce2",
        "Errors": [
          {
            "NextAction": "ddc0145a-ac98-445b-bb5a-b1aad3dc1441",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "PromptId": "arn:aws:connect:ap-northeast-1:xxxxxxxx: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"
          }
        ]
      }
    }
  ]
}

テストで何度も住所を発話するため、ループブロックを利用しています。

発話終了時に録音停止

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

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

ヒアリングした住所の音声出力

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

音声出力では、「コンタクト属性の設定」ブロックでaudio_outputを定義し、プロンプト再生で音声出力します。後の処理で利用する可能性もありますので、addressも定義しています。

<speak>
    ご住所は、
        <prosody rate="slow">
            $.Attributes.audio_output
        </prosody>
    、ですね。
</speak>

テスト結果

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

電話をかけた後、例えば、「熊本県熊本市中央区手取本町3-5 アーバンフロントレジデンス熊本 1107」と住所を発話すると、LambdaがConnectフローに以下を返しつつ、ログに残ります。addressと発話内容が同じか確認し判定をします。

再掲ですが、各キーは以下の通りです。

  • transcript:Whisper APIでの文字起こし内容
  • address:transcriptから住所を抽出 + 整形
  • audio_output:addressを発音用に整形
  • result:住所の抽出結果。success or failure
{
  "transcript": "熊本県熊本市 中央区 手取本町3-5 アーバンフロントレジデンス 熊本 1107号室",
  "address": "熊本県熊本市中央区手取本町3-5 アーバンフロントレジデンス熊本1107号室",
  "audio_output": "熊本県、熊本市、中央区、手取本町、3の5、アーバンフロントレジデンス熊本、1107号室",
  "result": "success"
}

ただし、発話内容と地名の漢字は異なりますが、音声出力(Amazon Polly)での発音が同じ場合、◯と判定します。

ちなみに、住所を読み取れない場合、failureで返されます。

{
  "transcript": "テスト テスト テスト テスト テスト",
  "address": "failure",
  "audio_output": "",
  "result": "failure"
}

結果は以下の通りです。発話内容とaddressを比較し判定します。transcriptaudio_outputも載せます。resultは、全てsuccessが返りました。

発話内容 transcript
address
audio_output
判定
熊本県熊本市中央区手取本町3-5 アーバンフロントレジデンス熊本 1107 熊本県熊本市 中央区 手取本町3-5 アーバンフロントレジデンス 熊本 1107号室
熊本県熊本市中央区手取本町3-5 アーバンフロントレジデンス熊本1107号室
熊本県、熊本市、中央区、手取本町、3の5、アーバンフロントレジデンス熊本、1107号室
三重県津市雲出(くもず)本郷町1237番地 三重県津市 久保津 本郷町1237番地
三重県津市久保津町本郷町1237番地
三重県、津市、久保津町、本郷町、1237番地
千葉県千葉市美浜区高洲3-7-12 ベイサイドマンション705号室 千葉県千葉市三浜区高須3-7-12 ベイサイドマンション705号室
千葉県千葉市美浜区高洲3-7-12 ベイサイドマンション705号室
千葉県、千葉市、美浜区、高洲、3の7の12、ベイサイドマンション、705号室

(1)
新潟県新潟市中央区万代(まんだい)6-2-8 グランドビュー1003号室 新潟県新潟市中央区 万代 6-2-8 グランドビュー 1003号室
新潟県新潟市中央区万代6-2-8 グランドビュー1003号室
新潟県、新潟市、中央区、万代、6の2の8、グランドビュー、1003号室

(2)
岡山県岡山市北区中央町5-6-7 岡山県岡山市北区中央町5-6-7
岡山県岡山市北区中央町5-6-7
岡山県、岡山市、北区、中央町、5の6の7
静岡県静岡市葵区追手町410番地3号 静岡県静岡市 青井区 御手町 410番地 3号
静岡県静岡市葵区御手町4-10-3
静岡県、静岡市、葵区、御手町、4の10の3

(1)
埼玉県さいたま市浦和区常盤9丁目1番地5号 埼玉県埼玉市浦和区時話9丁目、1番地、5号。
埼玉県さいたま市浦和区常盤9-1-5
埼玉県、さいたま市、浦和区、常盤、9の1の5

(1)
東京都渋谷区渋谷1-2-3 サンシャインマンション501号室 東京都渋谷区渋谷1-2-3サンシャインマンション501号
東京都渋谷区渋谷1-2-3 サンシャインマンション501号室
東京都、渋谷区、渋谷、1の2の3、サンシャインマンション、501号室
秋田県秋田市手形住吉町6-5 秋田県秋田市手形住吉町6-5
秋田県秋田市手形住吉町6-5
秋田県、秋田市、手形住吉町、6の5
福岡県福岡市博多区住吉1-3-2 グランドハイツ201 福岡県福岡市博多区住吉 1-3-2 グランドハイツ 201
福岡県福岡市博多区住吉1-3-2 グランドハイツ 201号室
福岡県、福岡市、博多区、住吉、1の3の2、グランドハイツ、201号室
鹿児島県鹿児島市与次郎2-7-11 サンシャインシティ503号室 鹿児島県鹿児島市 与次郎 2-7-11サンシャインシティ503号室
鹿児島県鹿児島市与次郎2-7-11 サンシャインシティ503号室
鹿児島県、鹿児島市、与次郎、2の7の11、サンシャインシティ、503号室
神奈川県横浜市西区みなとみらい4-5-6 神奈川県横浜市西区港未来4-5-6
神奈川県横浜市西区みなとみらい4-5-6
神奈川県、横浜市、西区、みなとみらい、4の5の6

(1)
石川県金沢市香林坊(こうりんぼう)4-20 金沢フォレストレジデンス 2002号室 石川県金沢市 高林房 4-20 金沢フォレストレジデンス 2002号室
石川県金沢市高林4-20 金沢フォレストレジデンス 2002号室
石川県、金沢市、高林、4の20、金沢フォレストレジデンス、2002号室

(6)
宮城県仙台市青葉区本町2-10-8 シティハイツ703 宮城県仙台市青幕区本町2-10-8シティハイツ703
宮城県仙台市青葉区本町2-10-8 シティハイツ703号室
宮城県、仙台市、青葉区、本町、2の10の8、シティハイツ、703号室

(1)
長崎県長崎市浜町7-15 シーサイドパレス201 長崎県長崎市浜町7-15シーサイドパレス201
長崎県長崎市浜町7-15 シーサイドパレス201号室
長崎県、長崎市、浜町、7の15、シーサイドパレス、201号室
愛知県名古屋市西区那古野2丁目8番地10号 愛知県名古屋市西区 名古屋の2丁目8番地10号
愛知県名古屋市西区名古屋2-8-10
愛知県、名古屋市、西区、名古屋、2の8の10

(6)
島根県松江市白瀉本町(しらかたほんまち)18番地 島根県松江市白方本町18番地
島根県松江市白潟本町18番地
島根県、松江市、白潟本町、18番地

(4)
宮崎県宮崎市橘通東4丁目8-12 グランドハイツ602 宮崎県宮崎市立花通り東 4丁目8-12グランドハイツ602
宮崎県宮崎市立花通東4-8-12 グランドハイツ602号室
宮崎県、宮崎市、立花通東、4の8の12、グランドハイツ、602号室

(3)
大阪府大阪市北区梅田2-3-4 ブリリアンタワー1005 大阪府、大阪市、北区、梅田、2-3-4、ブリリアンタワー、1005
大阪府大阪市北区梅田2-3-4 ブリリアンタワー1005号室
大阪府、大阪市、北区、梅田、2の3の4、ブリリアンタワー、1005号室
山口県下関市細江町3-2-18 山形県下関市細江町3-2-18
山口県下関市細江町3-2-18
山口県、下関市、細江町、3の2の18

(1)
沖縄県那覇市おもろまち3丁目4番5号 パームガーデン102 沖縄県那覇市小室町3丁目4番5号 パームガーデン102
沖縄県那覇市小禄1丁目4-5 パームガーデン102号室
沖縄県、那覇市、小禄1丁目、4の5、パームガーデン、102号室

(6)
京都府京都市下京区河原町通り夷川上る(えびすがわあがる)2番地 京都府京都市下行区河原町通り 恵比寿川上る 2番地
京都府京都市下京区河原町通り恵比寿上る2番地
京都府、京都市、下京区、河原町通り恵比寿上る、2番地

(1)
沖縄県宜野湾市真志喜1-1-1アパート真志喜101号室 沖縄県宜野湾市 麻敷1-1-1 アパート麻敷10157
沖縄県宜野湾市真志喜1-1-1 アパート真志喜101号室
沖縄県、宜野湾市、真志喜、1の1の1、アパート真志喜、101号室

(1)、(5)
広島県広島市中区基町7-8-9 広島県広島市中区本町7-8-9
広島県広島市中区本町7-8-9
広島県、広島市、中区、本町、7の8の9
埼玉県川口市大字石神1020番地2号 埼玉県川口市大和座石上、1020番地2号。
埼玉県川口市大和田町石上1020-2
埼玉県、川口市、大和田町、石上、1020の2
北海道札幌市中央区大通西6-5 ノースタワー札幌 45号室 街道札幌市中央区大通西6-5、ノースタワー札幌、45号室です。
北海道札幌市中央区大通西6-5 ノースタワー札幌 45号室
北海道、札幌市、中央区、大通西、6の5、ノースタワー札幌、45号室

(3)
福岡県福岡市南区井尻3-12-25 福岡県福岡市南区伊地里3-12-25
福岡県福岡市南区伊地里3-12-25
福岡県、福岡市、南区、伊地里、3の12の25
青森県青森市安方2-15-8 グリーンシティ青森 12階 青森県青森市安方2-15-8 グリーンシティ青森12階
青森県青森市安方2-15-8 グリーンシティ青森12階
青森県、青森市、安方、2の15の8、グリーンシティ青森、12階
岩手県盛岡市内丸2-7-1 ザ・シティタワーズ盛岡 28階 岩手県青森岡市内丸2-7-1 ザ・シティタワーズ森岡28階
岩手県盛岡市内丸2-7-1 ザ・シティタワーズ盛岡 28階
岩手県、盛岡市、内丸、2の7の1、ザ・シティタワーズ盛岡、28階

(1)
青森県三戸郡五戸町倉石中市内川原51番地 青森県 丹の辺郡 五の辺町 倉石仲市 内河原51番地
青森県上北郡おいらせ町中里字内河原51番地
青森県、上北郡、おいらせ町、中里字内河原、51番地

総評です。

  • 認識精度は、67%(20/30)
  • (1)文字起こしされた時点では地名が間違っていますが、Claudeでの整形によって、存在する地名に変換される
  • 発話通りに文字起こしされるが、以下のように音声出力(Amazon Polly)で誤った読み方になることがある
    • (2)万代は、「まんだい」ではなく「もず」
    • (3)通は、「どおり」ではなく「つう」
    • (4)本町は、「ほんまち」ではなく、「ほんちょう」
    • (5)宜野湾市真志喜の真志喜は、「ましき」ではなく、「まさしき」と音声出力。ただし、アパート真志喜は、「ましき」と音声出力されます。
  • 建物名は、精度が高く発話通りに文字起こしされている
  • ✕の判定になる要素
    • 文字起こしの処理(transcript
      • 文字起こし内容が異なる
      • 文字起こし内容の読みは合っているが地名の漢字が異なる
    • 住所の整形(address
      • 正しい地名に変換されない
      • 地名の一部が削除される(そもそも正しい地名ではないが)
        • (6)高林房 → 高林
        • (6)小室町 → 小禄
        • (6)名古屋の → 名古屋

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

住所の認識精度向上の方法

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

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

2点目は、処理方法の変更です。例えば、郵便番号を先に聞いておくことで、郵便番号APIサービスを利用し、町名以降の住所のみを発話することで、精度を高めることができます。ただし、郵便番号と住所の2つを伝える手間があります。

最後に

Amazon Connectにおいて、Amazon Bedrock + Whisper APIの組み合わせで、発話による住所のヒアリング精度を確認してみました。

今回のテストの場合、67%の精度でしたが、雑音がする場所や発話するイントネーションなどによっては精度が変わる可能性が大いにあります。

電話の無人対応において、住所のヒアリング精度が気になっていた方の参考になれば幸いです。