基于YandexSpeechKit的语音向导的实现

Internet上提供了各种实现,但是我认为它们都很简单。 我想为星号介绍我的语音指南版本。


注意:我不是专业的程序员,也许某些解决方案对您来说似乎很疯狂。 一些技巧可能已过时。 我准备接受批评并改进系统。


功能简介:


用户输入IVR,提出请求,并且在大多数情况下,到达所需的位置。 统计信息也通过mysql表中的条目固定在系统上。
简要介绍公司和部署此系统的网络:
约1000个电话,约50个部门


系统使用的软件产品:


  • 星号13.10
  • YandexSpeechKit
  • python 2.6.6
  • MySQL,MSSQL
  • 袜14.2.0
  • 卷曲7.19.7
  • me脚3.99.5

星号中拨号计划的描述。


[officevoicerec] exten => s,1,Answer() same => n,Macro(hangercheck,${CALLERID(num)}) same => n,Set(ITERATIONS=1) same => n,Set(HANGFLAG=TRUE) same => n,Background(/var/lib/asterisk/sounds/ru/speechrec/zdravstvuite) 

在此片段中,启动宏以检查呼叫者在听到问候消息后是否已挂断。 接下来是设置变量的值:
ITERATIONS-必须重复识别过程一定次数。 HANGFLAG-Hangercheck宏使用此变量。


 same => n(rec),Set(RECFILE=/tmp/${UNIQUEID}.wav) same => n,Playback(/var/lib/asterisk/sounds/en/beep) same => n,Record(${RECFILE},3,8) same => n,AGI(pyreq8.py,${RECFILE}) same => n,GotoIf($["${NUMTOCALL}" = "repeat"]?repeat) same => n,Set(HANGFLAG=FALSE) 

设置记录文件变量,写入文件。 我们运行agi脚本,负责发送文件以进行识别和数字搜索(脚本的描述将在后面给出),检查NUMTOCALL变量(该值由脚本设置),设置符号HANGFLAG,这意味着该人没有提前挂断电话。


 same => n,Macro(VRstat,${CALLERID(num)},${NUMTOCALL},${RSTATUS},${CHANNEL},${RECREZ}) same => n,GotoIf($[[${EXISTS(${FNAME})}]]?foundName:havenodescr) same => n(foundName),Set(FILE_FNAME=${STRREPLACE(FNAME, ,)}) same => n,GotoIf($["${STAT(f,/var/lib/asterisk/sounds/ru/cache/${FILE_FNAME}.mp3)}"="1"]?havecache:nocache) 

在此片段中,运行宏以检查呼叫者在听到问候消息后是否挂断,然后设置变量。ITERATIONS必须重复指定的次数才能识别。 HANGFLAG-Hangercheck宏使用此变量。


 same => n(rec),Set(RECFILE=/tmp/${UNIQUEID}.wav) same => n,Playback(/var/lib/asterisk/sounds/en/beep) same => n,Record(${RECFILE},3,8) same => n,AGI(pyreq8.py,${RECFILE}) same => n,GotoIf($["${NUMTOCALL}" = "repeat"]?repeat) same => n,Set(HANGFLAG=FALSE) 

设置记录文件变量,写入文件。 我们运行负责发送文件以进行识别和数字搜索的脚本(脚本的说明将在下面),检查NUMTOCALL变量(该值由脚本设置),设置HANGFLAG标志以使该人未提前挂断。


 same => n,Macro(VRstat,${CALLERID(num)},${NUMTOCALL},${RSTATUS},${CHANNEL},${RECREZ}) same => n,GotoIf($[[${EXISTS(${FNAME})}]]?foundName:havenodescr) same => n(foundName),Set(FILE_FNAME=${STRREPLACE(FNAME, ,)}) same => n,GotoIf($["${STAT(f,/var/lib/asterisk/sounds/ru/cache/${FILE_FNAME}.mp3)}"="1"]?havecache:nocache) same => n(nocache),System(curl "https://tts.voicetech.yandex.net/generate?format=mp3&lang=ru-RU&speaker=zahar&emotion=neutral&speed=0.8&key=" -G --data-urlencode "text= ${FNAME}." > /tmp/speech-${UNIQUEID}.mp3) same => n,System(/usr/local/bin/lame -S --scale 30 /tmp/speech-${UNIQUEID}.mp3 /var/lib/asterisk/sounds/ru/cache/${FILE_FNAME}.mp3) same => n(havecache),Playback(/var/lib/asterisk/sounds/ru/cache/${FILE_FNAME}) same => n,Dial(Local/${NUMTOCALL}@common-context) 

运行vrstat宏(负责统计,由于其琐碎而将不再描述)。 检查请求的FNAME描述(变量由pyreq8.py设置)。 如果有描述,请在变量中设置高速缓存文件名并检查其是否存在。 如果文件不存在,我们将其合成,将其转换为mp3,增大音量,然后(或者如果存在缓存)播放并调用订户。


 same => n(repeat),GotoIf($["${ITERATIONS}"="1"]?secretary) same => n,Background(/var/lib/asterisk/sounds/ru/speechrec/1-wav) same => n,Set(ITERATIONS=$[${ITERATIONS}+1]) same => n,Goto(rec) 

重复识别。 如果迭代次数超过指定的次数,那么我们将转换为秘书。


  same => n(secretary),Macro(VRstat,${CALLERID(num)},${NUMTOCALL},${RSTATUS},${CHANNEL},${RECREZ}) same => n,Set(HANGFLAG=FALSE) same => n,Dial(Local/1000@common-context) 

转交给秘书。 我们输入统计信息,设置他们没有挂断的标志。 我们打电话给秘书。


 same => n(havenodescr),Playback(/var/lib/asterisk/sounds/ru/speechrec/wait2);thanks-wait) same => n,Noop('no description') same => n,Dial(Local/${NUMTOCALL}@common-context) same => n,Hangup() 

呼叫在目录中没有描述的订户。 我们丢失了消息,将其输入统计数据中,然后调用它。


 exten => h,1,Gotoif($["${HANGFLAG}"="TRUE"]?exec:noop) same => n(exec),Macro(VRstat,${CALLERID(num)},x,HANGER,${CHANNEL}) same => n(noop),Noop('exiting') 

处理呼叫终止以使hangercheck宏正常工作。


拨号计划说明。 宏。


 [macro-hangercheck] ;${ARG1} -clid exten => s,1,GotoIf($["${ARG1}"="anonymous"]?end) exten => s,n,MYSQL(Connect connid SRV user password db utf8) exten => s,n,MYSQL(SET NAMES utf8) exten => s,n,MYSQL(Query resultid ${connid} SELECT IFNULL((SELECT clid from ivr_stat where rstatus="HANGER" and calldate >=ADDDATE(NOW(),INTERVAL -48 HOUR) and clid="${ARG1}" order by calldate desc limit 1),"NF")) exten => s,n,MYSQL(Fetch fetchid ${resultid} VAR) exten => s,n,MYSQL(Clear ${resultid}) exten => s,n,MYSQL(Disconnect ${connid}) exten => s,n,GotoIf($["${VAR}"="NF"]?end) exten => s,n,Macro(VRstat,${ARG1},x,H_RECALL,${CHANNEL}) exten => s,n,Dial(Local/1000@common-context) exten => s,n,Hangup() exten => s,n(end),Noop(Hanger check failed) 

我们在统计数据库中检查给定的号码是否呼叫了最近48小时,如果他挂了电话而没有等待识别结束,则将其输入统计并连接到秘书。


使用过的sql和mysql表的简短描述。


SQL表dbo.phrases。


id(PK, int), phrase (text), number(varchar(50))


对该表进行了全文搜索;当接收到长短语时,它用于按关键字切换。 例如,短语“请与我联系广告部门的代表”,如果数据库中有一个短语为“广告部门”的条目,则呼叫者将连接到相应的号码。


Mysql表recogStats。


id(PK, int), date (datetime), ctime(float), rtime(float),stime(float), phrase(varchar(60))


该表用于存储识别结果并收集有关识别时间的统计信息。 ctime-转换音频文件所花费的时间,rtime-下载和识别所花费的时间,stime-搜索所花费的时间


mysql表ivr_stat。


id(PK, int), calldate (datetime),clid(varchar(15)),duration(int(20)),callednum(varchar(10)),rstatus(varchar(20)),channame(varchar30)),RECREZ(varchar(200))


该表用于跟踪识别结果(RECREZ,rstatus),调用者根据识别结果的短语(被称为num)的搜索结果找到谁,以保持统计信息,了解一个人在识别菜单上花费的时间(持续时间)和调试时间(channame)


Mysql CustomRequests表。


id(PK, int), nomer(int(10)), request(varchar(100))
其他组和部门的辅助目录。


Mysql numdescriptions表。


Num(text), name(text)


具有输入的语音代理号码说明的目录。


Mysql表spravochnik_rus和spravochnik_rus_name_num_no_tops


nomer(varchar(11)), fio(varchar(100))


带数字的参考人。 第一个是完整的,供内部使用。 第二种是供外部使用,其董事和经理的人数被包装在一个秘书处中。


AGI脚本,用于转换,发送和搜索已识别的短语。


二手图书馆:


 import difflib from sys import exit import uuid import time import os import subprocess import xml.etree.ElementTree import MySQLdb import pymssql import string import re from itertools import permutations from os import remove import timeit 

还有asterisk.agi库,我们根据调试导入(调试在Windows计算机上完成)。


变量说明。


 WINDEBUG = False 

调试标志


 _digits = re.compile('\d') 

将正则表达式变量编译为数字。


 uniqid = str(uuid.uuid1()).replace('-', '') 

唯一标识符变量。


 dkey = '12345678-9101-1121-3141-51617181920' 

Yandex语音工具包API密钥。


 lang = 'ru-RU' 

Yandex语音包的语言选项。


 topic = 'queries' 

Yandex语音包的识别主题选项。


 callnumber = '222' 

默认电话号码。


 setVar('NUMTOCALL', callnumber) 

设置默认电话号码,以防出现问题。


 persondic = dict() 

词典名称-数字。


 persondicFI=dict() 

FI字典-数字。


 persondicF=dict() 

字典的姓氏-数字。


 otherdic = dict() 

具有“记录-编号”形式的其他条目的字典。


 descriptions = dict() 

带有用于生成声音代理的描述的字典,键入Record-number。


 duplicates = list() 

过滤需要的服务列表。


 nums = list() 

列出所有扩展名。


 outfile = '/tmp/' + uniqid + '-pcm.wav' 

可变的临时输出音频文件。


 mysqlhost='myhost' mysqlpass='mypass' mysqluser='myuser' mysqldb='mydb' mssqlhost='mshost' mssqlpass='mspass' mssqluser='msuser' mssqldb='msdb' 

连接到mysql和mssql的详细信息。


 if not WINDEBUG: from asterisk.agi import * agi = AGI() infile = agi.env['agi_arg_1'] caller = agi.get_variable('CALLERID(num)') else: caller = '1064' infile = '' 

导入库,根据调试为输入音频文件的名称和调用者的号码设置一个变量。


功能说明


 def verb(s): if not WINDEBUG: agi.verbose(s) else: print s 

根据调试变量的值,我们在星号控制台或stdout中显示消息。


 def setVar(varname, varval): if not WINDEBUG: agi.set_variable(varname, varval) else: print "setting var " + varname + " with value " + varval 

根据调试变量的值,我们将Dialplan变量的值分配给星号或将输出分配给stdout。


 def contains_digits(s): verb('enter contains_digits') return bool(_digits.search(s)) 

检查s是否包含数字。


 def return_digits(s): verb('return digits') pstr = s.encode("utf-8") all = string.maketrans('', '') nodigs = all.translate(all, string.digits) return unicode(pstr.translate(all, nodigs), "utf-8") 

我们仅返回s中的数字。


 def check_dob(num): if int(num) in nums: verb('checkdob success') return True else: verb('checkdob fail' + num) return False 

检查nums列表中是否存在扩展名。


 def set_dob(strnum): buf = return_digits(strnum) if contains_digits(strnum): if check_dob(buf): verb('setting var ' + buf) setVar('RSTATUS', 'SAYDIAL') return buf else: return "repeat" else: return "repeat" 

扩展设置功能。


 def checkSize(infile): if int(os.stat(infile).st_size) <= 26364: setVar('NUMTOCALL', 'repeat') setVar('RSTATUS', 'SILENCE') verb('empty file received') remove(infile) exit(9) 

检查接收到的录音文件是否可识别,如果尺寸太小,我们假定它们在听筒中没有声音。


 def addSessionStat(ctime, rtime, stime, phrase): cdate = time.strftime("%Y-%m-%d %H:%M:%S") db = MySQLdb.connect(host=mysqlhost, user=mysqluser, passwd=mysqluser, db=mysqldb, charset='utf8') cur = db.cursor() cur.execute( "INSERT INTO recogStats(date,ctime,rtime,stime,phrase) VALUES ('" + cdate + "','" + str(ctime) + "','" + str( rtime) + "','" + str(stime) + "','" + phrase + "')") db.commit() db.close() 

录音统计识别功能。


 def fillDics(): db = MySQLdb.connect(host=mysqlhost, user=mysqluser, passwd=mysqlpass, db="central_cdr", charset='utf8') cur = db.cursor() if len(caller) != 4: tbname = 'spravochnik_rus_name_num_no_tops' else: tbname = 'svravochnik_rus_persons' cur.execute("""SELECT nomer,fio from """ + tbname) for row in cur.fetchall(): fullfio=row[1].lower() f=" ".join(fullfio.split(' ')[0:1]) if not f in persondicF.keys():# and f not in duplicates : persondicF[f] = int(row[0]) elif fullfio in persondic.keys(): pass else: duplicates.append(f) if not fullfio in persondic.keys(): persondic[fullfio] = int(row[0]) fi=" ".join(fullfio.split(' ')[0:2]) if not fi in persondicFI.keys(): persondicFI[fi] = int(row[0]) elif fullfio in persondic.keys(): pass else: persondicFI.pop(fi) uniquedups=[x for x in list(set(duplicates)) if x != ''] for item in uniquedups: for key in persondicF.keys(): if item in key: persondicF.pop(key) cur.execute("""SELECT nomer,request from CustomRequests """) for row in cur.fetchall(): otherdic[row[1].lower()] = row[0] cur.execute("""SELECT num,name from numdescriptions """) for row in cur.fetchall(): descriptions[str(row[0])] = row[1] cur.execute("""SELECT nomer from spravochnik_rus """) for row in cur.fetchall(): nums.append(int(row[0])) db.close() 

此功能填写字典的姓氏(persondicF),姓氏(persondicFI),名称(persondic),部门名称(otherdic),语音合成说明(description)和所有公司编号(nums)。
根据呼叫者电话号码的长度,会生成一个包含主管(内部呼叫者)或不包含(外部呼叫者)号码的表。
测试姓氏目录的唯一性,以排除其中存在两个具有不同编号的伊凡诺夫的情况。


 def convert(infile, outfile): #convert file verb("Converting WAV " + infile) soxconvert = subprocess.Popen(['sox', infile, '-r', '16000', '-b', '16', '-c', '1', outfile], stdout=subprocess.PIPE) (out, err) = soxconvert.communicate() # remove(infile) if soxconvert.returncode != 0: setVar('NUMTOCALL', callnumber) # return "" exit(9) 

万一sox报错,转换星号中记录的文件的功能,设置默认编号并退出脚本。


 def sendRecog(file): verb("Sending file to yandex: " + outfile) proc = subprocess.Popen(['curl', '--max-time', '5', '--silent', 'asr.yandex.net/asr_xml?key=' + dkey + '&uuid=' + uniqid + '&topic=' + topic + '&lang=ru-RU', '-F', 'Content-Type=audio/x-pcm;bit=16;rate=16000', '-F', 'audio=@' + outfile], stdout=subprocess.PIPE) (out, err) = proc.communicate() verb("return code is: " + str(proc.returncode)) if proc.returncode != 0: return "" remove(file) e = xml.etree.ElementTree.fromstring(out) if e.attrib['success'] == '1': verb(e._children[0].text) return e._children[0].text else: return "" 

发送用于识别的文件并从Yandex接收响应的功能。 如果识别成功,我们将采取第一个答案。 我无法使用curl库执行此操作,因此使用了此选项。


 def searchfiobyf(f): if len(f.split(" "))==2: limit=2 elif len(f.split(" "))>=3: return f else: limit=1 for fio,num in persondic.iteritems(): cutfio=" ".join(fio.split(' ')[0:limit]) if f == cutfio: return fio 

搜索全名的功能,取决于输入的是姓氏还是姓氏和名字。


 def combinationSearcher(phrase,pdic,quality): verb(u'searching in persons') result = difflib.get_close_matches(phrase, pdic.keys(), 1, quality) #verb(" ".join(result)) if len(result) > 0: fullfio=searchfiobyf(result[0]) verb(u'found ' + str(pdic[result[0]])) setVar('RSTATUS', 'NAMESUCCESS') setVar('FNAME', fullfio) return pdic[result[0]] else: return "" 

组合搜索功能,例如:短语=“ Alexey Petr”,pdic包含条目“ Alexeyev Peter”,get_close_matches函数将返回“ Alexeyev Peter”。 该函数可能会产生不正确的结果,但是,如实践所示,存在更多正确的响应。 quality参数使您可以指定搜索相似短语的准确性。


 def getNumByName(recognizedString): if u'' in recognizedString: verb('enter dobavochn') return set_dob(recognizedString) elif len(recognizedString.replace(" ", "")) == 4: verb('enter num say') return set_dob(recognizedString) if len(recognizedString) <= 5 and recognizedString.lower() not in [u"",u"",u""]: setVar('RSTATUS', 'SHORT') return "repeat" verb(u'start searching') #  split = list(set(recognizedString.split(" "))) parts = len(split) fixedstring=" ".join(split) if parts >= 5: verb('Phrase is long. Using FTDB') buf = mssqlwrapper(fixedstring) if buf != '': buf = mssqlwrapper(fixedstring)[0] if str(buf) in descriptions.keys(): setVar('FNAME', descriptions[str(buf)]) setVar('RSTATUS', 'DEPTSUCCESS') return buf else: setVar('RSTATUS', 'REQUESTNOTFOUND') return "repeat" result="" if parts == 1: mssqlcheck=mssqlwrapper(fixedstring) if mssqlcheck!='': if mssqlcheck[2]>=80: buf=str(mssqlcheck[0]) if buf in descriptions.keys(): setVar('FNAME', descriptions[str(buf)]) setVar('RSTATUS', 'DEPTSUCCESS') result=buf else: result=combinationSearcher(fixedstring,persondicF,0.7) elif parts ==2: combs = list(permutations(split, parts)) # (  ,   ,   .....) for item in combs: element = " ".join(item) result=combinationSearcher(element,persondicFI,0.7) if result!="": break elif parts ==3: combs = list(permutations(split, parts)) for item in combs: element = " ".join(item) result=combinationSearcher(element,persondic,0.8) if result!="": break if result!="": return result verb('Low ftdbsearch') buf = mssqlwrapper(recognizedString) if buf != '': buf = mssqlwrapper(recognizedString)[0] if str(buf) in descriptions.keys(): verb('it is') setVar('FNAME', descriptions[str(buf)]) setVar('RSTATUS', 'DEPTSUCCESS') return buf else: verb(u'item not found ' + recognizedString) #    setVar('RSTATUS', 'REQUESTNOTFOUND') return callnumber 

通过短语查找数字的主要功能。


让我们分部分分析此功能。


 if u'' in recognizedString: verb('enter dobavochn') return set_dob(recognizedString) 

示例:短语“ Please,extension 1234”将返回分机号(如果有)。


 elif len(recognizedString.replace(" ", "")) == 4: verb('enter num say') return set_dob(recognizedString) 

如果用户说出了四位数的号码,请返回相应的分机号(如果有)。


 if len(recognizedString) <= 5 and recognizedString.lower() not in [u"",u"",u""]: setVar('RSTATUS', 'SHORT') return "repeat" 

为了避免用简短的语气不必要地运行程序(当Yandex听到并识别出对话的一部分,而调用者未收听识别消息时,可能会发生这种情况)。


 if parts >= 5: verb('Phrase is long. Using FTDB') buf = mssqlwrapper(fixedstring) if buf != '': buf = mssqlwrapper(fixedstring)[0] if str(buf) in descriptions.keys(): setVar('FNAME', descriptions[str(buf)]) setVar('RSTATUS', 'DEPTSUCCESS') return buf else: setVar('RSTATUS', 'REQUESTNOTFOUND') return "repeat" result="" 

对于长短语(超过五个词),我们立即转向mssql FT数据库。


 if parts == 1: mssqlcheck=mssqlwrapper(fixedstring) if mssqlcheck!='': if mssqlcheck[2]>=80: buf=str(mssqlcheck[0]) if buf in descriptions.keys(): setVar('FNAME', descriptions[str(buf)]) setVar('RSTATUS', 'DEPTSUCCESS') result=buf else: result=combinationSearcher(fixedstring,persondicF,0.7) 

如果识别出的短语由一个单词组成,则首先我们查看FT数据库,其准确度为80%,查找对识别出的数字的描述,为数字计划设置结果变量和状态计划,否则我们在姓氏词典中搜索相似的词。


 elif parts ==2: combs = list(permutations(split, parts)) for item in combs: element = " ".join(item) result=combinationSearcher(element,persondicFI,0.7) if result!="": break 

如果该短语由两个单词组成,则假定它是一个姓氏和一个名字。
我们列出一个组合列表(Ivan Ivan,Ivan Ivan),并寻找相似的比赛。


 elif parts ==3: combs = list(permutations(split, parts)) for item in combs: element = " ".join(item) result=combinationSearcher(element,persondic,0.8) if result!="": break if result!="": return result 

对于三字词短语,我们假定它是一个全名,列出组合并在字典中查找一个全名。 如果变量不为空,则返回结果。


 buf = mssqlwrapper(recognizedString) if buf != '': buf = mssqlwrapper(recognizedString)[0] if str(buf) in descriptions.keys(): verb('it is') setVar('FNAME', descriptions[str(buf)]) setVar('RSTATUS', 'DEPTSUCCESS') return buf else: verb(u'item not found ' + recognizedString) #    setVar('RSTATUS', 'REQUESTNOTFOUND') return callnumber 

如果没有找到匹配项,则在FT数据库中进行搜索,如果找到匹配项,则返回结果和描述,否则返回默认数字并设置未找到该短语的状态。


该程序的主体。


 fillDics() if not WINDEBUG: checkSize(infile) start_time = timeit.default_timer() convert(infile, outfile) convert_elapsed = timeit.default_timer() - start_time start_time = timeit.default_timer() checkstring = sendRecog(outfile).lower() recog_elapsed = timeit.default_timer() - start_time verb('convert_elapsed = ' + str(recog_elapsed)) if checkstring == "": verb('not recognized. using default.') setVar('NUMTOCALL', 'repeat') setVar('RSTATUS', 'SILENCE') exit(9) else: setVar('RECREZ', checkstring) else: checkstring = u"" start_time = timeit.default_timer() callnumber = getNumByName(checkstring) search_elapsed = timeit.default_timer() - start_time if not WINDEBUG: setVar('NUMTOCALL', str(callnumber)) addSessionStat(convert_elapsed, recog_elapsed, search_elapsed, checkstring.lower()) 

启动填充字典的功能,如果我们不在调试程序中-我们填充计时器变量,检查文件大小,进行转换,将其发送以进行识别,否则我们用检查短语填充checkstring变量。
接下来,我们填充用于识别的计时器变量,然后在结构中搜索短语。 并且,在战斗功能方面,我们为编号计划设置变量并输入统计信息。


一些统计。


2018年6月:
成功认可-1010
沉默不语-78
识别失败(找不到匹配项)-79
简短申请-4
认可部门-7
平均识别时间(Yandex平均响应时间)-2.6秒


2017年6月:
成功认可-1271
认可部门-18
识别失败(找不到匹配项)-127
简短申请-9
在管子里沉默了-71
平均识别时间(Yandex平均响应时间)-1.5秒


此服务已在我工作的公司中成功使用。 我希望我的成就能够帮助其他人实现他们的想法或类似功能。 准备回答所有问题。

Source: https://habr.com/ru/post/zh-CN417273/


All Articles