蓝星际语音平台 脚本例子

  1. 这些例子都比较简单, 有些比较有趣,您很容易看明白,仔细阅读源程序可以让您迅速入门,也会提高您的水平。
  2. 每个例子都有一个说明文档,请您在运行例子之前阅读它。
  3. 请将蓝星际平台程序拷贝到相应的目录之下,然后运行。因为有些脚本使用了相对路径,所以建议脚本文件和蓝星际平台程序放在相同的目录。

地铁热线IVR的实现:脚本中的脚本

某些客户其语音流程经常会发生变化,虽然用脚本来表达其变化也很容易,但每次进行重复劳动毕竟是很烦琐的事情。最近深圳地铁热线要大规模地修改语音流程,其语音菜单层次繁多,但其操作仅仅是播放某个提示语音,不需要从后台数据库中取数据,便考虑给客户提供更方便的界面,比如采用配置文件的方式,这样完全不懂编程的用户可以自行调整流程,重录语音,增加、减少、合并、修改语音菜单,既灵活方便,也省去我们维护的麻烦。

那么,如何设计一个好的配置文件,既简单明白,又能够表现语音菜单的层次结构呢?
请看这个完整的配置文件:

#地铁流程配置文件:Menu.cfg
#注释以#开始,可以有空行

#每一行的格式为: [空格]文件名[,类型信息]
#      空格: 表示菜单的层次,根目录没有空格, 下一层与上一层之间的间隔是1个空格
#      类型信息: F-传真, R-留言, P-播放语音(缺省值,可不设置)
#返回上一层菜单和转人工座席不需要设置
#注意,用空格表示菜单的层次,而不是文件名,当然取有意义的文件名可以增加可读性,便于理解流程

#设计者:bluesen@sina.com, 2006.11

StartFile  #开始菜单
 1_1  #失物查询

 2_1  #首末班时间
  2_1_1  #1号线
  2_1_2  #4号线

 3_1  #车站信息
  3_1_1  #世界之窗...
   3_1_1_1
   3_1_1_2
   3_1_1_3
  3_1_2  #竹子林...
   3_1_2_1
   3_1_2_2
   3_1_2_3
  3_1_3  #香蜜湖...
   3_1_3_1
   3_1_3_2
   3_1_3_3
  3_1_4  #华强路...
   3_1_4_1
   3_1_4_2
   3_1_4_3
  3_1_5  #老街...
   3_1_5_1
   3_1_5_2
   3_1_5_3
  3_1_6  #少年宫...
   3_1_6_1
   3_1_6_2
   3_1_6_3

 4_1  #票务政策
  4_1_1  #车票介绍
  4_1_2  #票价政策
   4_1_2_1  #票价查询
   4_1_2_2  #车牌优惠政策
    4_1_2_2_1  #老年人
    4_1_2_2_2  #残疾人
    4_1_2_2_3  #学生

 5_1  #传真
  5_1_1.txt, F  #传真
  5_1_2.txt, F  #传真
  5_1_3.txt, F  #传真

 6_1, R #留言

#配置结束
		

我认为这个配置文件体现了简单明白的原则,允许空行,也允许#开始的注释,缺省的操作属性值可不需要配置。用缩进的空格来表示菜单的层次,比较直观,从配置文件就可以知道流程的结构。
这样的配置文件,不懂编程的地铁热线小姐,完全可以看懂并自行修改。
我设计的这个配置文件格式,按Eric S.Raymond在<Unix程序设计的艺术>一书中的说法,是属于微型语言,是最简单的脚本语言,既然这个微型语言要用Koodoo脚本语言实现,那就不妨戏称为“脚本中的脚本”。

那么剩下的问题就是如何来读取这个配置文件并运行了。
Koodoo语言支持文件读写操作,可以一行一行地读取;看来问题的关键是如何表现语音菜单流程,用怎么样的数据结构?语音菜单流程在内存中是颗树,在Koodoo语言中用数组或散列表就可以轻松表达,主要的数据结构是树中的节点,描述如下:
 m["File"] = "2_1_1";  // 文件名
 m["Type"] = "P";      // 操作类型: F-传真, R-留言, P-播放语音(缺省值,可不设置)
 m["SubNum"] = 2;      // 子节点个数, 如为0表示叶节点
 m["Level"] = 0;       // 本节点所在层数
 m["Parent"] = NULL;   // 父节点的下标, NULL表示没有父节点
 m[1] = 2;  // 按1对应的子节点下标
 m[2] = 5;  // 按2对应的子节点下标
 ...
所有的节点放在全局数组root[]中,本质上root描述了整个语音菜单树。

好了,“代码不会骗人”,还是看看具体的脚本代码实现的吧(代码并不长,我加了详细的注释):

// 全局常量和变量定义
const LOOP_NUM = 5;
root = 0;  // 用这个数组变量描述流程树

// 自定义函数
// 处理配置文件的每一行
// 输入参数:txt - 本行内容,字符串
// 输出参数:level - 本节点所在层次, 0为根节点
//           file - 本节点的文件名
//           type - 本节点类型,"P"-播音, "F"-传真, "R"-留言
// 返回值:true-是有效的节点, false-无效节点
function ProceTxtLine(txt, level, file, type)
{
  level = 0;
  file = "";
  type = "";
  isFirst = true;
  isType = false;

  // 扫描字符串中的每个字节
  for( ch in txt )
  {
     if( ch==" " || ch=="\t" )  // 如果是空格或制表符
     {
        if( isFirst )  // 前导空格表示节点的层次级别
           level ++;
     }
     else if( ch=="#" )  // 注释将被忽略
        break;
     else if( ch=="," )  // 逗号后面是类型信息
        isType = true;
     else
     {
        isFirst = false;
        
        if( isType )
           type = type + ch;
        else
           file = file + ch;
     }
  }
  
  if( file=="" )
     return(false);
  else
     return(true);
}


// 读取菜单配置文件
// 配置文件"Menu.cfg"必须在当前目录下
// 读取后将生成一系列的节点到全局的数组变量root中
// 返回值:0-打不开文件, >0 - 节点数
function ReadMenuCfg()
{
 hd = -1;  // 文件句柄
 FileOpen("Menu.cfg", "rt", hd);  // 打开菜单配置文件
 if( hd < 0 )
 {
   DispInfo(0, "Open 'Menu.cfg' Failed!");
   return(0);
 }

 s = "";
 f = "";
 t = "";
 lvl = 0;
 count = 0;  // 节点计数

 while(true)
 {
   // 读取一行
   ret = FileReadLine(hd, s);
   if( ret<0 )  // 文件结束了
      break;
   
   // 解析本行数据为: 节点层次,文件名,节点类型
   ok = ProceTxtLine(s, lvl, f, t);
   if( ok )
   {
      // 构造一个节点
      m["File"] = f;    // 文件名
      m["Type"] = t;    // 操作类型: F-传真, R-留言, P或""-播放语音(缺省值,可不设置)
      m["Level"] = lvl; // 本节点所在层数
      m["SubNum"] = 0;     // 子节点个数, 如为0表示叶节点, 子节点项: 为指针列表,如m[1], m[2]...
      m["Parent"] = NULL;  // 父节点的下标, 如为空则表示为根结点
      root[count] = m;
      
      if( lvl>0 )  // 如果不是根节点, 需计算其父节点
      {
        front = root[count-1];       // 取前面一项
        if( lvl == front["Level"] )  // 如果前面一项是自己的兄弟, 则他们有相同的父亲
           m["Parent"] = front["Parent"];
        else if( lvl == front["Level"]+1 )  // 如果前面的节点比自己少一层,则它是自己的父亲
           m["Parent"] = count-1;
        else
        {
           // 其它情况, 要往前搜索自己的兄弟        
           for(i=count-2; i>0; i--)
           {
             up = root[i];
             if( up["Level"]==lvl )
             {
                m["Parent"] = up["Parent"];
                break;  // 找到就退出
             }
             up = 0;
           }           
        }
        front = 0;


        // 修改父节点的"SubNum"属性, 即增加子节点个数
        parent = root[m["Parent"]];
        if( parent!=NULL )
        {
           n = parent["SubNum"] + 1;
           parent["SubNum"] = n;
           parent[n] = count;  // 增加子节点索引
        }
        parent = 0;
      }
      
      m = 0;
      count ++;
   }   
 }

 FileClose(hd);
 return(count);
}


// 功能说明: 提示菜单选择,接收用户按键,判断是否正确(0)
// 入口参数: voc-提示语音,k-得到的按键码,keysSet-所容许的按键码
function SelMenu(voc, k, keysSet)
{
  for(i=0; i<LOOP_NUM; i++)
  {
     Play(voc);
     k = "";
     GetKeys(k, 1, 10);
     if( k!="" )
     {
        keysLog = keysLog + k + ",";   // 按键记录

        in = false;
        Strstr(keysSet, k, in);
        if( keysSet=="" || in )
        {
           return(0);
        }
     }
  }
  Hangup();
}


// 运行某个节点
// 参数: idx - 节点下标, 0为起始节点也就是根节点
function RootRun(idx)
{
 m = 0;
 m = root[idx];
 n = m["SubNum"];  // 子节点个数, 如为0表示叶节点
 f = m["File"];    // 文件名
 t = m["Type"];    // 操作类型: F-传真, R-留言, P或""-播放语音(缺省值,可不设置)
 lvl = m["Level"]; // 本节点所在层数

 parent = m["Parent"];  // 父节点的下标, 如为空则表示为根结点
 if( n==0 )  // 叶节点
 {
   m = 0;
   // 根据节点类型进行具体的操作
   if( t=="P" || t=="" )
      Play(f);
   else if( t=="F" )
      DispInfo(0, "Fax..."); // Fax(f);
   else if( t=="R" )
      DispInfo(0, "Record...");  // RecordMail(f);
   return(parent);
 }
 else  // 菜单节点
 {
    k = "";
    // 生成本菜单的允许按键序列
    kSet = "";    
    for(i=1; i<=n;i++)
       kSet = kSet + i;
    kSet = kSet + "90";   // 总是需要:9-返回上一层菜单,0-转人工
    SelMenu(f, k, kSet);  // 让用户选择菜单
    Int(k, i);
    if( i==0 )
    {
        DispInfo(0, "Agent...");  // 转人工
        return(idx);
    }
    else if( i==9 )  // 返回上一层菜单
    {
        m = 0;
        DispInfo(0, "Up Menu...");
        if( parent!=NULL )
           return(parent);
        else
          return(idx);
    }
    else
    {
        ret = m[i];  // 取得按键对应的子节点索引
        return(ret);
    }
 }
} 
// 自定义函数结束


// --------------------

// 读取系统配置项
pthDir = ".\\Voice";  // 普通话路径
gzhDir = pthDir;  // 广州话路径

SetSndDir(pthDir, gzhDir, "");
SelectLan(0);    // 选择普通话

SetEndKey("#");  // 不足位数按#号结束
SetBreak(true);  // 可以打断
SetSndExt(".wav");

SetKeyTime( 8 );   // 设置两个按键之间的最大间隔

ret = ReadMenuCfg();
if( ret<=0 )
  return(0);
// --------------------

// 主循环
while(true)
{  
   WaitRing(2);

   Play("Welcome");  // "欢迎播打地铁服务热线"

   // 执行IVR流程
   idx = 0;  // 从根节点开始
   while(true)
   {
      idx = RootRun(idx);  // 循环执行
   }

   Hangup()
   OnDisconn();  // 响应用户挂机
   Sleep(2);
}

OnSysQuit();  // 响应系统退出
root = 0;
return(0);
		

我曾经在“图形化误区--再论IVR语音平台的选择”一文中,指出过:
“   回到IVR流程设计,我认为应该对各阶段的人群进行这样的划分:
1.IVR流程设计者,也就是采用语音平台开发工具的人,他们可能是集成商,也可能的用户单位电脑中心的开发人员,总之是懂电脑的人;
2.IVR系统的日常维护人员,可能很懂电脑,大部分是一般的应用水平;
3.IVR系统的使用者,他们是最终用户,在电话机上操作。”


本文中采用的简单配置文件,就是给“2.IVR系统的日常维护人员,可能很懂电脑,大部分是一般的应用水平;”使用的,也算是对我自己提出的开发原则的一种实践吧。


下载:

IVR脚本+配置文件+语音文件