[電話無人対応] Amazon Bedrock + Whisperで、名前のヒアリング精度を確認してみた[Amazon Connect]
はじめに
Amazon Connect + Amazon Bedrock + Whisper APIの組み合わせで、発話による名前(フルネーム)のヒアリング精度を確認してみました。
以前、Amazon ConnectとAmazon Lexを利用し、ヒアリング精度を確認しましたが、今回は、Amazon Bedrock + Whisper APIの組み合わせで確認してみます。
ヒアリング精度の確認方法は、発話によって名前を伝えた際、発話通り名前を認識するかAWS Lambdaのログから確認します。
構成
構成としては、下記の通りです。
名前のヒアリングに関して、Connectのフローは下記の通りです。
- コンタクトフロー内で「メディアストリーミングの開始」ブロックを使って、Kinesis Video Streams(KVS)への音声のストリーミングを開始します。
- 名前を発話します。
- 顧客の入力を保存する」ブロックで、顧客が発話を止めると、ストリーミングが終了します。Amazon Lexを裏で呼び出しています。
- 「AWS Lambda関数を呼び出す」ブロックを使い、LambdaでKVSからデータを取得します。取得したデータをWAV形式に変換し、Whisper APIで文字起こしします。文字起こし内容から、Amazon BedrockのClaudeで名前を抽出 + 整形します。
- 名前を抽出できていれば、確認のための音声出力をし、抽出できなければ、その旨を伝えます。
- 今回はしませんが、オペレーターにエスカレーションなども可能です。
以下の図は、電話での対話の流れを示しています。
もし、名前を認識できない場合、以下の通り、担当者に変える流れになります。今回紹介するフローでは音声出力のみで、実際には変えません。
前提
- 2024年3月28日時点での検証内容です。
- 発話のイントネーションや速度、周囲の雑音などにより検証結果が異なる可能性がありますので、ここでの結果は一例とご認識ください。
- 今後のバージョンアップによって、改善される可能性があり、恒久的な結果ではありません。
構築
AWS Lambda
ユーザーの発話内容を「メディアストリーミングの開始」ブロックを使って、KVSに保存後、Lambdaで以下の処理を行います。
- LambdaでKVSからメディアデータを取得します。
- メディアデータから音声データを抽出し、WAV形式に変換し、Lambdaのローカルに保存します。
- Whisper APIで音声ファイルに対して、文字起こしを実行します。
- Amazon Bedrock のClaudeで文字起こし内容を抽出と整形処理します。
- 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_kana
:full_name
をひらがなに変換したフルネームlast_name_kana
:full_name_kana
のうち苗字first_name_kana
:full_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_kana
とfirst_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%の精度でしたが、雑音がする場所や発話するイントネーションなどによっては精度が変わる可能性が大いにあります。
今後もユーザー体験を損なわずに精度を向上させる方法や、プロンプト調整を検証していきます。