コールセンターで、AIが親切に応対してくれる時代が来るでしょうか。技術的にはそういう自動応対が可能になってきています。最近の技術系記事で必ず目にするのが、ChatGPT(チャットジーピーティー)です。

皆さんはChatGPTを活用していますか?無料で始められますし、日本語で会話できますので、AIに触れてみるのには良い機会かもしれません。

ChatGPTに電話応対させてみた(Asterisk PBX編)

ChatGPTを開発しているOpenAIはAPI(エーピーアイ)という、他のソフトウェアから活用する仕組みが実装されています。今回は弊社ラボで、「Asterisk PBXを使用して、ChatGPTを開発する OpenAI社のAPIで電話応対が実現できるかテストしてみる」という実験を実施してみました。

Asterisk(アスタリスク)というのは、電話の世界では10年以上前から使用されている、内線電話やコールセンターを実現するための仕組みを持った電話交換機のソフトウェアです。

下記のような一連の流れを作り、その仕組みで「AIと会話する」ことを実現してみます。

文章にすると作業することがいくつかありますが、実際には非常に短い時間の中で設定ができます。すぐ実践できるのに、いざ試してみると大きな違和感なくAIと会話することができました。

ChatGPTで電話応対するまでの流れ

  1. 通話を開始する。お客様のAIへの質問部分を録音して、音声ファイルに変換する。
  2. 生成された音声ファイルから、音声認識(ASR)で文章に書き起こす。
  3. ChatGPT(OpenAIのAPI)にその質問を送信して返事を返してもらう。
  4. 返ってきた返事を音声合成(STT)で音声化する。

という流れになります。このすべては数秒で完了します。
それでは、さっそく実際のコンピューターのソースコードを見てましょう。

前提として考えておくこと

  • 今回は、Asteriskが日本中で売れた時期であるCentOS6の時代のサーバでも動作できることを想定する。
  • Asteriskは、nobody権限などの最小権限で動作させている場合があるので、ファイルアクセスができない場合のことも考える。
  • すると、FastAGIで別デーモンを作っておき、権限を変えることを考える。

といったことが必要になるかもしれません。様々なPBX製品でAsteriskが使用されていますが、この条件で作っておけば色々なサーバで動作しそうです。

電話システムAsterisk上で動作させる、「シナリオ」スクリプト

Asteriskの中では下記のような独自のコードで動きを指定します。いわば全体をまとめるお品書きのようなものになります。

[Test-DOZONO-ChatGPT]
include => external
exten => s,2,Set(CALLERID(name)=Test-CBA-ChatGPT)
exten => s,3,Playback(chatgpt2) # アナウンスを再生。「AIに何でも質問してください。」
#exten => s,4,AGI(set_astval.agi,QUESTION,あなたの名前は何ですか)
exten => s,5,AGI(cba_record.agi) # ここでお客様の質問を短時間で録音
exten => s,6,AGI(cba_amivoice_asr.agi) # 音声認識で文章にする
exten => s,7,AGI(agi://127.0.0.1:7890/call_chatgpt) # FastAGIを使用してOpenAIのAPIに問い合わせる
exten => s,8,AGI(agi://127.0.0.1:7890/txt2wav) # 返ってきた文字列を音声へ
exten => s,9,AGI(agi://127.0.0.1:7890/play_reply) # 音声データを再生して、お客様に聞かせる
exten => s,10,Playback(chatgpt4) # アナウンスを再生。「いかがだったでしょうか。このように回答ができます。」
exten => s,11,Hangup() # 通話切断。連続して会話するためには数行上に戻すようにも組める。

【1】通話で音声を受けて録音する。

Asteriskのスクリプトでは、下記のようなコードでお客様の声を録音して録音ファイルにすることができます。


#
# Recording
#
my $filename = "/var/lib/asterisk/agi-bin/temp_audio/sample";
my $format = "wav";
my $digits = "#";
my $timeout = "7000";
my $offset = "0";
my $beep = 1;
my $silence = "6";
$astagi->record_file($filename, $format, $digits, $timeout, $offset, $beep, $silence);
$astagi->verbose("recording finished.");

【2】録音した音声ファイルを音声認識で言葉にする。

CentOS6上でテストしたところ、Amivoiceさんの音声認識APIがいちばん使いやすく感じました。


my $AppKey = "************************************4267"; #Amivoice Appkey
chomp($AppKey);
my $dir = '/var/lib/asterisk/agi-bin/temp_audio'; 
my @result = (); 
opendir (my $audio, $dir) or die("error :$!"); 
while (my $audio_file = readdir $audio) { 
next if ($audio_file eq '.' || $audio_file eq '..'); 
#print "filename:".$audio_file."\n"; 
# Amivoice への問い合わせ 
my $return = `curl -X POST -F a=@"$dir/$audio_file" "https://acp-api.amivoice.com/v1/recognize?d=-a-general&u=$AppKey"`; 
# 返ってきたJSONをデコード
my $data = decode_json($return); 
#print $return."\n"; 
push @result, $data->{'text'}; 
open(OUT, "> /tmp/asr.result.txt") or die("error :$!"); 
foreach my $out (@result) { 
print OUT encode('UTF-8', $out), "\n\n"; 
$astagi->verbose(encode('UTF-8',$out)); 
$astagi->set_variable("QUESTION", $out); # AsteriskのQUESTIONという変数に質問文を入れる。
close(OUT); 
$astagi->verbose("Speech to Text finished.");

【3】ChatGPTに言葉を送信して返事をもらう。

こちらは、FastAGI部分のコードです。単に質問をそのままAPIに投げるとずらっと長い文章で返ってきてしまって文章の長さを指定できません。

そこで、プロンプトエンジニアリングの手法を研究する必要が生じます。今回は、「あなたはAIコールセンターのオペレータです。$questionという質問に対して、50文字以内にまとめて必ず日本語のですます調で答えてください」という指示文にしてみました。デモとしてはこれでうまく動作しました。


sub call_chatgpt {
    my $self = shift;
    my $API_KEY = "sk-ChatGPTのAPIキーを登録。";
    $self->agi->verbose("**HANDLER: call_chatgpt**");
    my $question = $self->agi->get_variable('QUESTION');
    my $last_question = "#あなたはAIコールセンターのオペレータです。「"
        .Encode::decode('utf-8',$question)."」という質問に対して、50文字以内にまとめて必ず日本語のですます調で答えてください。";
    $self->agi->verbose("Question:".$last_question); 
    my $ua = LWP::UserAgent->new; 
    my $req = HTTP::Request->new( POST => 'https://api.openai.com/v1/chat/completions' ); 
    $req->header('Content-Type' => 'application/json'); 
    $req->header('Authorization' => 'Bearer ' . $API_KEY); 
    my %post_data = ( "model" => "gpt-3.5-turbo", "messages" => [ { "role" => "user", "content" => $last_question, } ], ); 
    $req->content(encode_json(\%post_data)); 
    my $resp = $ua->request($req); 
    if ($resp->is_success) { 
      $self->agi->verbose("**Request succressfully finished**"); 
        my $resp_data = decode_json($resp->decoded_content); 
        $self->agi->verbose(Encode::encode('utf-8',$resp_data->{choices}->[0]->{message}->{content}."\n")); 
        my $reply = Encode::encode('utf-8',$resp_data->{choices}->[0]->{message}->{content}."\n"); 
        $self->agi->set_variable("AIREPLY", $reply); 
        my $tokens = $resp_data->{usage}->{total_tokens}; 
        $self->agi->verbose(Encode::encode('utf-8',"Used Token:".$tokens."\n")); 
        my $callerid = $self->input('callerid'); 
        open(DATAFILE, ">> /tmp/chatgpt.log"); 
        print DATAFILE scalar(localtime); 
        print DATAFILE " (".$callerid.") Q:".$question; 
        print DATAFILE " Ans:".$reply; 
        close(DATAFILE);
   } else {
        $self->agi->verbose("**Request failed**".$resp->status_line); 
        #die $resp->status_line;
    }
 }

【4】返事を音声化する。

今回は、以前に作ってあった OpenJTalk というオープンソースの音声合成を使ってみました。ここは現在、様々な製品が出てきていますので、「文章を音声化する」ために合ったものを選ぶことができます。

電話システムで大事なところは、「同時通話が発生する」ということを考慮する必要がある、ということです。Asteriskの場合、1サーバで100通話はさばけますので、音声認識、音声合成を同時にどれぐらい行えるようにするのか、というところを設計時に考えておく必要が生じます

【5】FastAGIでサーバを作成しておく。

AsteriskにはFastAGIという機能があり、外部サーバとして動かしておき、Asteriskサーバの処理を切り出す仕組みがあります。今回はそれを使ってファイル権限を適切にして動作させてみました。下記のようなコードになっています。


package CbaFastAgiAI;

use strict;
#use warnings;
use base 'Asterisk::FastAGI';
use LWP::UserAgent;
use Net::SSL;
use JSON;
use Encode;
use utf8;

#
# HANDLERS
#

sub hello {
  my $self = shift;
  $self->agi->verbose("**HANDLER: hello**");
  $self->agi->verbose("Hello, This is a test FASTAGI");
  #$self->agi->say_number(1234);
}

sub call_chatgpt {
    my $self = shift;
    my $API_KEY = "sk-ChatGPTのAPIキーを登録。";
    $self->agi->verbose("**HANDLER: call_chatgpt**");
    my $question = $self->agi->get_variable('QUESTION');
    my $last_question = "#あなたはAIコールセンターのオペレータです。「".Encode::decode('utf-8',$question)."」という質問に対して、50文字以内にまとめて必ず日本語のですます調で答えてください。";
    $self->agi->verbose("Question:".$last_question);

    my $ua = LWP::UserAgent->new;
    my $req = HTTP::Request->new(
     POST => 'https://api.openai.com/v1/chat/completions'
    );
    $req->header('Content-Type' => 'application/json');
    $req->header('Authorization' => 'Bearer ' . $API_KEY);
    my %post_data = ( 
            "model" => "gpt-3.5-turbo",
            "messages" => [
                    {   
                            "role" => "user",
                            "content" => $last_question,
                    }   
            ],  
    );
    $req->content(encode_json(\%post_data));
    my $resp = $ua->request($req);
    if ($resp->is_success) {
        $self->agi->verbose("**Request succressfully finished**");
        my $resp_data = decode_json($resp->decoded_content);
        $self->agi->verbose(Encode::encode('utf-8',$resp_data->{choices}->[0]->{message}->{content}."\n"));
        my $reply = Encode::encode('utf-8',$resp_data->{choices}->[0]->{message}->{content}."\n");
        $self->agi->set_variable("AIREPLY", $reply);
        my $tokens = $resp_data->{usage}->{total_tokens};
        $self->agi->verbose(Encode::encode('utf-8',"Used Token:".$tokens."\n"));
        my $callerid = $self->input('callerid');
    
        open(DATAFILE, ">> /tmp/chatgpt.log");
        print DATAFILE scalar(localtime);
        print DATAFILE " (".$callerid.") Q:".$question;
        print DATAFILE " Ans:".$reply;
        close(DATAFILE);

        
    } else {
        $self->agi->verbose("**Request failed**".$resp->status_line);
        #die $resp->status_line;
    }
}

sub txt2wav {
    my $self = shift;
    $self->agi->verbose("**HANDLER: txt2wav**");
    my $reply = $self->agi->get_variable('AIREPLY');
    $self->agi->verbose("REPLY:".$reply);
    
    my $fname = "ai_reply_".int(rand(100000));
    my $ai_reply_txt_file = "/tmp/".$fname.".txt";
    my $first_asterisk_wav = "/tmp/".$fname.".wav";
    my $final_asterisk_wav = "/var/lib/asterisk/sounds/".$fname.".wav";

    $self->agi->set_variable("AI_REPLY_WAV", $final_asterisk_wav);
    $self->agi->verbose("ai_reply_txt_file:".$ai_reply_txt_file);
    $self->agi->verbose("first_asterisk_wav:".$first_asterisk_wav);
    $self->agi->verbose("final_asterisk_wav:".$final_asterisk_wav);
    
    #reply to ai_reply_txt_filename
    open(DATAFILE, ">> ".$ai_reply_txt_file);
    print DATAFILE $reply;
    close(DATAFILE);

    #file to wav
    my @cmd = ('/bin/bash','/home/*****/openjtalk/make_aireply.sh',$ai_reply_txt_file,$first_asterisk_wav,$final_asterisk_wav);
    my $ret = system(@cmd);
    if(!$ret){
        $self->agi->verbose("**HANDLER: txt2wav OK.** ");
    } else {
        $self->agi->verbose("**HANDLER: txt2wav Failed.** ");
    }
}

sub play_reply {
    my $self = shift;
    my $wavfilename = $self->agi->get_variable("AI_REPLY_WAV");
    $self->agi->verbose("AI_REPLY_WAV:".$wavfilename);
    $wavfilename =~ s/^(.*)\..*$/$1/; #delete extension
    $self->agi->exec("PLAYBACK",$wavfilename);
    $self->agi->verbose("**HANDLER: play_reply OK.** ");
}

#
# End
#

1;

今後の開発

AIに関する研究と、それを利用した応用製品の開発も広がっています。リアルタイムに音声を文字に起こし、さらにインタラクティブな仕方で会話をできるようにするためにはさらに改良を加え、工夫していく必要があります。

また、「OpenAIのAPIに直接アクセスするのではなく、自社のFAQや文書データベースを先に調査してからChatGPIを活用する」ような仕組みも次々に発表されています。コールセンターに電話をすると、最初の応答がAIで、様々な情報を親切な仕方で教えてくれる製品も今後、ますます増えていくに違いありません。