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

2024.04.02

はじめに

Amazon Connect + Amazon Bedrock + Whisper APIの組み合わせで、発話による名前(フルネーム)のヒアリング精度を確認してみました。

以前、Amazon ConnectとAmazon Lexを利用し、ヒアリング精度を確認しましたが、今回は、Amazon Bedrock + Whisper APIの組み合わせで確認してみます。

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

構成

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

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

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

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

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

前提

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

構築

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, 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()

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 = "Please write all names in hiragana only and do not use kanji at all."
    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>
        - 名前は、以下の4つをJSON形式で出力ください。
            1. フルネーム
            1. ひらがなに変換したフルネーム
            1. ひらがなに変換した苗字(Last Name)のみ
            1. ひらがなに変換した名前(First Name)のみ
        - 名前を読み取れない場合、result値はfailureにしてください。読み取れた場合、result値はsuccessにしてください。
        - JSON形式以外は出力しないでください。
    </rule>
    ## 例
    {{
      "full_name": "山田太郎",
      "full_name_kana": "やまだたろう",
      "last_name_kana": "やまだ",
      "first_name_kana": "たろう",
      "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)
    print("Received json_data:" + json.dumps(json_data, ensure_ascii=False))
    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)

    return extract_json_format(transcript)
  • 執筆時点では、東京リージョン未サポートのClaude3を利用するため、us-east-1を指定しています。
  • プロンプトは、名前を抽出しつつ、ひらがなに変換するように指定しています。
    • 漢字をひらがなに変換するPythonのライブラリもありますが、生成AIでひらがなに変換します。理由は後述します。
  • returnでは以下の情報をConnectフローに返します。
    • full_name:Whisper APIでの文字起こし内容から抽出したフルネーム
    • full_name_kanafull_nameをひらがなに変換したフルネーム
    • last_name_kanafull_name_kanaのうち苗字
    • first_name_kanafull_name_kanaのうち名前
    • result:名前の抽出結果。success or failure

Whisper APIには、ひらがなを使うよう以下のプロンプトを設定しています。

system_prompt = "Please write all names in hiragana only and do not use kanji at all."

上記を設定しない場合、Whisper APIで文字起こしすると、以下のように名前が漢字に変換された状態で文字起こしされます。

例として、ChatGPTで考えてもらった名前「すずき たかし」を発話すると以下の結果となります。

発話内容 full_name full_name_kana last_name first_name 判定
すずき たかし 鈴木隆 すずきりゅう すずき りゅう

「鈴木隆」と文字起こしされた後、Claudeでひらがなに変換すると「たかし」ではなく「りゅう」と変換されました。

上記のことが起きないよう、プロンプトを設定すると、全てではなく一部ですが、ひらがなで文字起こしされます。

発話内容 full_name full_name_kana last_name first_name 判定
すずき たかし 鈴木たかし すずきたかし すずき たかし

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": ["7e2c9e03-4463-4193-8829-e029cfe03909"],
        "overrideConsoleVoice": true,
        "fragments": {
          "SetContactData": "7e2c9e03-4463-4193-8829-e029cfe03909"
        },
        "overrideLanguageAttribute": true
      },
      "7e2c9e03-4463-4193-8829-e029cfe03909": {
        "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 }
      },
      "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
      },
      "49007031-591c-41e6-a446-ad9ac799e081": {
        "position": { "x": 255.2, "y": 449.6 },
        "parameters": {
          "LambdaFunctionARN": { "displayName": "cm-hirai-whisper-name" }
        },
        "dynamicMetadata": {}
      },
      "17b3d6aa-cb41-4601-b8fd-47a19c08dce2": {
        "position": { "x": 658.4, "y": 0 },
        "parameters": { "PromptId": { "displayName": "Beep.wav" } },
        "promptName": "Beep.wav"
      },
      "a65b7aed-cf7d-4a4c-b468-42350f2ae79d": {
        "position": { "x": 1127.2, "y": 456 }
      },
      "a9643ec3-3551-4413-9a6d-5fc1ff04ddaf": {
        "position": { "x": 484, "y": 451.2 },
        "conditionMetadata": [
          {
            "id": "d1cfd2cd-8c6a-4674-abbb-027d1547983a",
            "operator": {
              "name": "Equals",
              "value": "Equals",
              "shortDisplay": "="
            },
            "value": "success"
          }
        ]
      },
      "ea1420e6-dce0-482e-96bd-c509bd3e1425": {
        "position": { "x": 912, "y": 452 }
      },
      "ddc0145a-ac98-445b-bb5a-b1aad3dc1441": {
        "position": { "x": 1124.8, "y": 671.2 }
      },
      "f57d84b5-a511-4214-a271-8761733a8553": {
        "position": { "x": 698.4, "y": 455.2 },
        "parameters": {
          "Attributes": {
            "last_name_kana": { "useDynamic": true },
            "first_name_kana": { "useDynamic": true }
          }
        },
        "dynamicParams": ["last_name_kana", "first_name_kana"]
      },
      "5bf35837-3aad-4374-91f1-9ca0c27c5afc": {
        "position": { "x": 40, "y": 452.8 }
      },
      "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": "ae0ed830-b31d-40a6-8d85-6908c3f51110",
            "operator": {
              "name": "Equals",
              "value": "Equals",
              "shortDisplay": "="
            },
            "value": "OneVoice"
          }
        ]
      },
      "d1aa2cd0-2c59-4321-a479-c83670e51601": {
        "position": { "x": 477.6, "y": 209.6 }
      }
    },
    "Annotations": [],
    "name": "cm-hirai-bedrock-whisper-name",
    "description": "",
    "type": "contactFlow",
    "status": "saved",
    "hash": {}
  },
  "Actions": [
    {
      "Parameters": {
        "TextToSpeechEngine": "Neural",
        "TextToSpeechStyle": "None",
        "TextToSpeechVoice": "Kazuha"
      },
      "Identifier": "6b6a5984-8096-4946-987b-54537088a266",
      "Type": "UpdateContactTextToSpeechVoice",
      "Transitions": { "NextAction": "7e2c9e03-4463-4193-8829-e029cfe03909" }
    },
    {
      "Parameters": { "LanguageCode": "ja-JP" },
      "Identifier": "7e2c9e03-4463-4193-8829-e029cfe03909",
      "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": { "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": {
        "LambdaFunctionARN": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxx:function:cm-hirai-whisper-name",
        "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": {
        "PromptId": "arn:aws:connect:ap-northeast-1:xxxxxxxxxx: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": "お名前は、$.Attributes.last_name_kana 、$.Attributes.first_name_kana、様、ですね。\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": { "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": { "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": {
        "Attributes": {
          "last_name_kana": "$.External.last_name_kana",
          "first_name_kana": "$.External.first_name_kana"
        },
        "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": { "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": {
        "Text": ",",
        "LexV2Bot": {
          "AliasArn": "arn:aws:lex:ap-northeast-1:xxxxxxxxxx: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": {
        "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"
          }
        ]
      }
    }
  ]
}

発話終了時に録音停止

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

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

ヒアリングした名前の音声出力

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

音声出力では、「コンタクト属性の設定」ブロックでlast_name_kanafirst_name_kanaを定義し、プロンプト再生に分けて音声出力します。

お名前は、$.Attributes.last_name_kana 、$.Attributes.first_name_kana、様、ですね。

テスト結果

名前のサンプルは、ChatGPTで名前を考えてもらったものになります。

結果は以下の通りです。例えば、「さとうだいすけ」と発話すると、LambdaがConnectフローに以下を返します。

{
  "full_name": "佐藤大輔",
  "full_name_kana": "さとうだいすけ",
  "last_name": "さとう",
  "first_name": "だいすけ"
}
発話内容 full_name full_name_kana last_name first_name 判定
さとう だいすけ 佐藤大輔 さとうだいすけ さとう だいすけ
すずき たかし 鈴木 たかし すずきたかし すずき たかし
たなか ゆうま 田中ゆうま たなかゆうま たなか ゆうま
いとう そうた 伊藤聡歌 いとうそうか いとう そうか
やまもと しょうた 山本翔太 やまもとしょうた やまもと しょうた
たかはし みさき 高橋みさき たかはしみさき たかはし みさき
こばやし ゆい 小林優位 こばやしゆうい こばやし ゆうい
わたなべ かおり 渡辺香織 わたなべかおり わたなべ かおり
なかむら まゆみ 中村真由美 なかむらまゆみ なかむら まゆみ
よしだ あいみ 吉田愛美 よしだまなみ よしだ まなみ
いのうえ りょうや 井上じょうや いのうえじょうや いのうえ じょうや
さいとう しゅうへい 斉藤周平 さいとうしゅうへい さいとう しゅうへい
まつもと ひろき 松本ひろき まつもとひろき まつもと ひろき
たけうち まもる 竹内まもる たけうちまもる たけうち まもる
いしかわ ようこ 石川陽子 いしかわようこ いしかわ ようこ
ささき かな ささき かな ささきかな ささき かな
やまだ はなこ 山田花子 やまだはなこ やまだ はなこ
たなか りこ たなかりこ たなかりこ たなか りこ
まつだ れな 松田レナ まつだれな まつだ れな
はやし みほ はやしにほ null null null
はせがわ そら 長谷川空 はせがわそら はせがわ そら
かとう りく 加藤 陸 かとうりく かとう りく
かねこ あん かねこあん かねこあん かねこ あん
もちづき ひろと もちづきひろと もちづきひろと もちづき ひろと
おおさわ しおり 大沢しおり おおさわしおり おおさわ しおり
あさお ゆいな 朝を言いな。 null null null
かわむら きよし 河村清 かわむらきよし かわむら きよし
みうら えいた 三浦英太 みうらえいた みうら えいた
やぎさわ こういち ヤギサワ・コウイチ やぎさわこういち やぎさわ こういち
みぞの りんか ニゾノリンカ null null null
たけだ えみ 武田 恵美 たけだえみ たけだ えみ
かわむら あおい 河村 葵 かわむらあおい かわむら あおい
みうら かなえ 三浦 カナエ みうらかなえ みうら かなえ
もり じろう 森 儀郎 もりぎろう もり ぎろう
やました ちから 山下力 やましたりき やました りき
しみず さぶろう 清水さぶろう しみずさぶろう しみず さぶろう
いしかわ しげる 石川茂 いしかわしげる いしかわ しげる
おがわ つとむ おがわ、つとむ おがわ、つとむ おがわ つとむ
えんどう かずこ 遠藤和子 えんどうかずこ えんどう かずこ
ふくだ ゆうこ 福田裕子 ふくだゆうこ ふくだ ゆうこ
おかもと きよこ 岡本清子 おかもときよこ おかもと きよこ
ふじもと ひろこ ふじもと ひろこ ふじもとひろこ ふじもと ひろこ
なかの ようこ なかのようこ なかのようこ なかの ようこ
うえだ みのる 上田 御乃 うえだみの うえだ みの
くぼ まさる くぼまさる くぼまさる くぼ まさる
しまだ しげまさ 島田しげまさ しまだしげまさ しまだ しげまさ
すぎもと しょういち 杉本翔一 すぎもとしょういち すぎもと しょういち
きくち えつこ きくちえつこ きくちえつこ きくち えつこ
こまつ はる 小松はる こまつはる こまつ はる
ほんだ まこと 本田誠 ほんだまこと ほんだ まこと

総評です。

  • 認識率は、80%(◯が40/50)
    • △が1,✕が9
  • 判定が△のケースは、Whisper APIによって漢字で文字起こしされるため、Claudeで漢字からひらがなに変換する際に、意図しないひらがなに変換されたものです。
    • ヒアリングで正しく聞き取れていますが、テキスト化した漢字によって、ひらがな変換時にべつの読み方になるようです。
    • 山下力(やましたちから) → やましたりき
  • 判定が✕となるケースにはいくつかのパターンが存在します。
    • Whisper APIによる誤った文字起こしによって、誤った名前で出力
    • Whisper APIによる誤った文字起こしによって、Claudeがひらがなに変換できず、nullで出力
      • 「朝を言いな。」のように、名前ではない文字起こしされることも。
  • Whisper APIのプロンプトで、ひらがなで文字起こしする設定にしましたが、漢字で文字起こしされることが多いです。
  • Whisper APIでは以下のパターンで文字起こしされることがあります。
    • 漢字で文字起こし
      • 佐藤大輔
    • スペースが空く
      • ふじもと ひろこ
      • 上田 御乃
      • 森 儀郎
      • ささき かな
    • 句読点がつく
      • おがわ、つとむ
    • カタカナで文字起こし
      • 三浦 カナエ
      • 松田レナ
    • 中点がつく
      • ヤギサワ・コウイチ

Whisper APIによる文字起こしでは、名前が基本的に漢字で出力されました。ひらがなへの変換にはPythonのライブラリを使用することも可能ですが、前述のように、スペースが空いたり句読点が挿入されたりするなど、予期せぬ形で文字起こしが行われることがあります。

Claudeであれば、漢字をひらがなに変換しつつ、よしなにスペースや句読点、中点の削除し整形してくれるので、生成AIでひらがなに変換しています。

漢字ではなくひらがなに変換する理由は以下の2点があります。

  • 漢字によってはAmazon Pollyの音声出力で誤った読み方になる可能性があります
  • 名前の漢字は、本人の名前の漢字と一致する可能性が低く、ヒアリングした名前をDBと突き合わせると失敗します

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

ゆっくり話す

先ほど✕や△判定された名前について、ゆっくり発話すると判定が変わるか確認しました。

結果は以下の通りです。

発話内容 full_name full_name_kana last_name first_name 判定
いとう そうた 伊藤 宗太 いとうそうた いとう そうた
こばやし ゆい 小林ひゆい 小林ひゆい こばやし ひゆい
よしだ あいみ 吉田あいみ よしだあいみ よしだ まなみ
いのうえ りょうや 井上涼也 いのうえりょうや いのうえ じょうや
はやし みほ はやし みほ はやしみほ はやし みほ
あさお ゆいな null
(「朝を言いな」と文字起こし)
null null
みぞの りんか みどのりんか null null null
もり じろう 織二郎 おじじろう おり じろう
やました ちから 山下力 やましたりき やました りき
うえだ みのる うえだ・みのる うえだ・みのる うえだ みのる

一定の効果があることが分かりました。

ひらがなへの誤変換

判定が△であった名前「力」が「りき」とひらがな変換されましたが、苗字が異なる場合も同様に漢字「力」と文字起こしされ、ひらがな変換で「りき」となるか確認します。

発話内容 full_name full_name_kana last_name first_name 判定
やました ちから 山下力 やましたりき やました りき
すずき ちから null
(「続き、力。」と文字起こし)
null null null
いとう ちから 伊藤力 いとうりき いとう りき
こばやし ちから 小林力 こばやしりき こばやし りき
もり ちから 森力 もりりき もり りき
いのうえ ちから null
(「胃の上、力」と文字起こし)
null null null
あさおちから null
(「あさおちから」と文字起こし)
null null null

Whisper APIでは「あさおちから」と文字起こしされますが、Claudeでは名前と認識されませんでした。

検証で確認した名前において、名前が「ちから」の場合、苗字が異なる場合も同様にWhisper APIで「力」と文字起こしされ、Claudeによるひらがな変換で「りき」になりました。

名前の認識精度向上の方法

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

1点目は、Whisper APIのプロンプトの調整です。今回紹介したプロンプトでは全てをひらがなで文字起こしされませんでした。全てひらがなで変換されることで、今回紹介した△判定を減らすことができるでしょう。

2点目は、Claudeのプロンプトの調整です。漢字で文字起こしされた名前をひらがなにする際、一般的な名前の読み方に変換するプロンプトを設定できれば、今回紹介した△判定を減らすことができるでしょう。

3点目は、ユーザーに対してゆっくりとはっきりと発話するよう促すことで、今回のテストでは一定の認識精度の向上が見られました。しかし、これはユーザーの協力が必要なため、最適な解決策とは言いにくいです。

最後に

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

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

今後もユーザー体験を損なわずに精度を向上させる方法や、プロンプト調整を検証していきます。