阅读 2004

前端实现人工智能回复的功能

前言

说到人工智障,相信大家应该都记得之前的那个仅有几个replace的智能回复吧。

image.png

嗯,非常的智能。

20160417854814_tPmgKw.gif

我们都知道中文博大精深,一句话可以有非常多种的解释。

举个例子:

他赞成我不赞成?

他赞成,我不赞成。

他赞成我不?赞成。

他赞成我?不赞成。

是吧,所以如果我们要实现一个非常聪明的智能是很难的。

不过若干智能助手也是有名的蠢了。

所以,我实现一个蠢但没完全蠢的智能回复应该问题不大。

我们来看一下demo演示:

8.gif

演示中有个小bug,说今天天气的时候回复中有多个天气,下面代码中已经修复。

github项目地址:github.com/lionet1224/…

思路

一开始我是想通过词义来解析一句话。

区分词的类型,如:名词、动词、形容词...等等,然后通过权重将这些词关联起来,最后总结出一个最匹配的回答。

不过实现起来感觉很复杂就放弃了。

image.png

后面就想着,我可以简化这个过程啊,不去区分词的类型,直接就是在所有定义好的句子中取到最匹配的那条。

句子是定义好的回答模板

例如:

  1. 我发送: 我喜欢点赞
  2. 那么我喜欢点赞可以解析为一个数组['我', '喜欢', '点赞']
  3. 然后在一个保存所有句子的数组中取得最匹配的那条句子
  4. 最后调用这条句子的回答方法:我也是并且我已经点了

那么基于这个思路我们就可以开发了。

准备工作

我们需要用到nodejieba这个node库,所以就需要启动一个node服务。

NodeJieba是"结巴"中文分词的 Node.js 版本实现, 由CppJieba提供底层分词算法实现, 是兼具高性能和易用性两者的 Node.js 中文分词组件。 - github.com/yanyiwu/nod…

先安装一下koa及其相关的库吧。

npm install koa2 koa-router koa-static nodejieba nodemon --save

然后在根目录中创建一个文件server.js来编写对应的服务代码。

实现的功能不复杂,就是将编写一个api可以将前端输入的句子分解成数组然后返回给前端,并且创建一个静态文件服务器来展示页面。

const nodejieba = require('nodejieba')
const Koa = require('koa2')
const Router = require('koa-router')
const static = require('koa-static')
const app = new Koa();
const router = new Router();

// 一个get请求
router.get('/word', ctx => {
  // cut就是nodejieba来分解句子的方法
  // ctx.query.word 获取链接中"?word=x"的x
  ctx.body = nodejieba.cut(ctx.query.word);
})

// 创建静态文件服务器
app.use(static('.'))
// 应用路由
app.use(router.routes())

// 监听端口启动服务
app.listen(3005, () => {
  console.log('start server *:3005')
})
复制代码

那么我们在package.json中写入对应的启动命令。

{
  // ...
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "nodemon server.js"
  },
  // ..
}
复制代码

启动成功后,我们可以在终端中看到启动成功的显示。

image.png

编写前端代码

首先我们需要一个界面来展示及操作我们的对话。

这里不管你是仿QQ也好还是仿微信、钉钉什么的,都行,实现了就好。

我就不贴代码了,有兴趣的可以去github中看看。

image.png

实现对应的发送信息的逻辑。

// 人工智障一代
function AImsg1(str){
  return str.replace(/[??]/g, '!')
            .replace(/[吗嘛]/g, '')
            .replace(/[你我]/g, (val) => {
              if(val === '我') return '你'
              else return '我'
            });
}

// 判断一下使用哪一代智能
// 默认使用二代
function getAImsg(str, type = 2){
  return new Promise(resolve => {
    if(type === 1){
      resolve(AImsg1(str))
    } else {
      AImsg2(str).then(res => {
        resolve(res)
      })
    }
  })
}

// 输入框
let inputMsg = document.querySelector('#input-msg')
// 发送按钮
let emitBtn = document.querySelector('#emit')
// 显示信息的容器
let wrapper = document.querySelector('.content');

// 用户发送信息,并且让AI也发送
function emitMsg(){
  let str = inputMsg.value;
  insertMsg(str, true);
  // 发送后清空输入框
  inputMsg.value = ''

  // 延迟一秒回复
  setTimeout(() => {
    getAImsg(str).then(res => {
      insertMsg(res);
    });
  }, 1000);
}

// 插入到页面中,flag来判断是用户发送的还是电脑发送的
function insertMsg(str, flag){
  let msg = document.createElement('div');
  msg.className = 'msg ' + (flag ? 'right' : '')
  msg.innerHTML = `<div>${str}</div>`;
  wrapper.appendChild(msg);

  wrapper.scrollTop = 100000;
}

emitBtn.onclick = () => {
  emitMsg();
};
// 回车键也可以发送
inputMsg.addEventListener('keyup', ev => {
  if(ev.keyCode === 13) emitMsg();
})
复制代码

通过上面的代码,我们就实现了发送信息的功能。

定义句子

可以将句子理解为一个模板,如果用户发送的话匹配了一条句子,那么人工智能就使用这条句子回复。

接下来,我们来实现句子的定义。

// 定义句子的类
// 句子
class Sentence{
  constructor(keys, answer){
    // 关键词
    this.keys = keys || [];
    // 回答的方法
    this.answer = answer;
    // 存储可变数据
    this.typeVariable = {};
  }
}
复制代码

可以看出,我在其中定义了keys/answer/typeVeriable三个变量。

  • keys就是用来匹配的关键词
  • answer这是一个方法,当匹配了这条句子之后就调用这个方法返回对应的话
  • typeVariable这个是为了让回答不那么死板,可以将一些可变的词提取出来然后在answer进行判断,最后返回合适的回答。

简单的实现

我们先不考虑可变通的回答,先实现一个最简单的问答。

我说: 天气是蓝色的

智能回复: 嗯嗯,天空是蓝色滴

// 我们可以先实现一下AImsg2这个方法,以方便调用
function  AImsg2(str){
  return new Promise(resolve => {
    // 获取前面开发的那个api的数据,将用户输入的文字当做参数。
    axios.get(`http://localhost:3005/word?word=${str}`).then(res => {
      console.log(res.data)
      // 去匹配适合的句子
      let sentences = matchSentence(res.data);

      // 如果没有匹配的回复
      if(sentences.length <= 0){
        resolve('emm,你在说什么呀,没看懂呢')
      } else {
      // 如果有匹配的就去获取回答
        resolve(sentences[0].sentence.get())
      }
    })
  })
}
复制代码

来实现一下matchSentence这个方法。

// 匹配最适合的句子
// 低于30%的匹配当做不匹配
function matchSentence(arr){
  let result = [];
  sentences.map(item => {
    // 用句子类自身的match方法判断是否匹配,返回匹配成功的关键词数量
    let matchNum = item.match(arr);
    // 如果匹配数量低于总关键词数量的1/3就当做没看到
    if(matchNum >= item.keys.length / 3) result.push({
      sentence: item,
      // 这里是匹配的关键词与总关键词数量的比例,为了方便排序最合适的那条
      searchNum: matchNum / item.keys.length
    })
  })
  
  result = result.sort((a, b) => b.searchNum - a.searchNum);
  return result;
}
复制代码

Sentence中实现matchget方法。

class Sentence{
  // ...
  
  // 获取用户发送的语句与定义的句子的匹配程度
  match(arr){
    // 每次匹配都重置数据
    this.typeVariable = {};
    // 将其解构放入一个新数组(浅拷贝功能)
    // 为了数据不会影响去匹配下一个句子
    let userArr = [...arr];
    let matchNum = this.keys.reduce((val, item) => {
      // 关键词是否匹配
      let flag = userArr.find((v, i) => {
        return v === val;
      })
      return val += flag ? 1 : 0;
    }, 0)

    return matchNum;
  }
  
  // 调用回答方法并且将变量传入
  get(){
      return this.answer(this.typeVariable)
  }
}
复制代码

最后添加句子的实例存入数组。

let sentences = [
  new Sentence(['天', '天空', '是', '蓝色', '颜色'], type => {
    let str = '嗯嗯,天空是蓝色滴';

    return str;
  }),
]
复制代码

不出意外的话,我们输入天空是蓝色的就将会得到回复:嗯嗯,天空是蓝色滴

image.png

为什么关键词是['天', '天空', '是', '蓝色', '颜色']这样的?

因为我们可能的问法有天是什么颜色/天空的颜色是蓝色的,所以我们可以将更多的关键词加入,以方便匹配。

变通的回答

如果仅仅是上面的写法,我们就只能非常死板的回答,所以,我们可以给一些关键词定义为可变的数据,最后取到这些数据来灵活的回答。

例如:爸爸的爸爸叫什么?

我们先来定义一个类,来保存一个种类的关键词。

// 种类,为一个系列的文字,如颜色的赤橙黄绿青蓝紫、时间的今天明天后天
class Type{
  // key是关键词
  // arr是这个种类下的词
  // exclude 排除关键词
  constructor(key, arr, exclude){
    this.key = key;
    this.arr = arr;
    this.exclude = exclude || [];
  }

  // 判断是否匹配
  match(str){
    return this.arr.find(item => {
      return str.indexOf(item) > -1;
    }) && this.exclude.indexOf(str) <= -1
  }
}
复制代码

然后创建对应语句的实例:

let sentences = [
  // 使用%x%的语法来表示可变数据
  new Sentence(['%family%', '的', '%family%', '叫', '是', '什么'], type => {
    let data = {
      '爸爸': {
        '爸爸': '爷爷',
        '妈妈': '奶奶'
      },
      '妈妈': {
        '爸爸': '姥爷',
        '妈妈': '姥姥'
      },
    }

    // 判断是否拥有这个叫法
    let result = data[type.family[0]] && data[type.family[0]][type.family[1]]

    // 最后返回
    if(result){
      return `${type.family[0]}${type.family[1]}${result}喔`
    } else {
      return '咳咳,我不知道诶'
    }
  }),
]

let types = {
  // 创建family这个种类
  family: new Type('family', ['爸爸', '妈妈', '哥哥', '姐姐', '妹妹', '弟弟', '外公', '外婆', '婆婆', '爷爷'])
}
复制代码

当然定义好了之后还需要在Sentence中编写获取可变数据的方法。

class Sentence{
  // ...

  // 获取用户发送的语句与定义的句子的匹配程度
  match(arr){
    this.typeVariable = {};
    let userArr = [...arr];
    let matchNum = this.keys.reduce((val, item) => {
      let flag = userArr.find((v, i) => {
        // 使用正则匹配%x%的写法,并且获取x的数据
        let isType = /^%(.*)%$/.exec(item);
        
        if(isType){
          // 判断关键词是否在这个种类中
          let matchType = types[isType[1]].match(v)

          if(matchType){
            // 存入typeVariable中
            if(!this.typeVariable[isType[1]]) this.typeVariable[isType[1]] = [];
            this.typeVariable[isType[1]].push(v);
            // 匹配过后,这个存入的数据应该删除,不然后面匹配的时候会将第一个数据重复输入
            userArr.splice(i, 1)
          }

          return matchType;
        } else {
          return item === v;
        }
      })
      return val += flag ? 1 : 0;
    }, 0)


    return matchNum;
  }

  // ...
}
复制代码

到这里变通回答的功能就实现了,我们来看看效果。

image.png

更多的功能

可以增加的功能还是挺多的,如:

  • 回复可以增加一些随机性,我可以回复你:我有点喜欢你,也可以说:我超级喜欢你。
  • 可变的数据获取可以增加更多的选择,不只是匹配Type,还可以指定某个关键词后面接着的关键词,如:我喜欢点赞,那么我就取到点赞这个关键词。
  • 在调用answer时调用其他的API,以实现更好的功能,如:获取天气、获取地点、获取土味情话。
  • 区分不同性格的回答,内向的人、外向的人、热情的人、冷淡的人拥有不一样的语气。

还有更多的想法就不一一列举了。

最后

感谢大家的阅读,此文仅为抛砖引玉,代码质量不佳请勿见怪。

image.png (诚挚的眼神)

文章分类
前端
文章标签