前言
说到人工智障,相信大家应该都记得之前的那个仅有几个replace
的智能回复吧。
image.png
嗯,非常的智能。
20160417854814_tPmgKw.gif
我们都知道中文博大精深,一句话可以有非常多种的解释。
举个例子:
他赞成我不赞成? 他赞成,我不赞成。 他赞成我不?赞成。 他赞成我?不赞成。
是吧,所以如果我们要实现一个非常聪明的智能是很难的。
不过若干智能助手也是有名的蠢了。
所以,我实现一个蠢但没完全蠢的智能回复应该问题不大。
github项目地址:github.com/lionet1224/…
思路
一开始我是想通过词义来解析一句话。
区分词的类型,如:名词、动词、形容词...等等,然后通过权重将这些词关联起来,最后总结出一个最匹配的回答。
不过实现起来感觉很复杂就放弃了。
image.png
后面就想着,我可以简化这个过程啊,不去区分词的类型,直接就是在所有定义好的句子中取到最匹配的那条。
句子是定义好的回答模板
例如:
- 我发送: 我喜欢点赞
- 那么我喜欢点赞可以解析为一个数组
['我', '喜欢', '点赞']
- 然后在一个
保存所有句子的数组
中取得最匹配的那条句子 - 最后调用这条句子的回答方法:我也是并且我已经点了
那么基于这个思路我们就可以开发了。
准备工作
我们需要用到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可以将前端输入的句子分解成数组然后返回给前端,并且创建一个静态文件服务器来展示页面。
代码语言:javascript复制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
实现对应的发送信息的逻辑。
代码语言:javascript复制// 人工智障一代
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
进行判断,最后返回合适的回答。
简单的实现
我们先不考虑可变通的回答,先实现一个最简单的问答。
代码语言:javascript复制我说: 天气是蓝色的 智能回复: 嗯嗯,天空是蓝色滴
// 我们可以先实现一下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
中实现match
及get
方法。
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)
}
}
复制代码
最后添加句子的实例存入数组。
代码语言:javascript复制let sentences = [
new Sentence(['天', '天空', '是', '蓝色', '颜色'], type => {
let str = '嗯嗯,天空是蓝色滴';
return str;
}),
]
复制代码
不出意外的话,我们输入天空是蓝色的
就将会得到回复:嗯嗯,天空是蓝色滴
。
image.png
为什么关键词是['天', '天空', '是', '蓝色', '颜色']
这样的?
因为我们可能的问法有天是什么颜色
/天空的颜色是蓝色的
,所以我们可以将更多的关键词加入,以方便匹配。
变通的回答
如果仅仅是上面的写法,我们就只能非常死板的回答,所以,我们可以给一些关键词定义为可变的数据,最后取到这些数据来灵活的回答。
例如:爸爸的爸爸叫什么?
我们先来定义一个类,来保存一个种类的关键词。
代码语言:javascript复制// 种类,为一个系列的文字,如颜色的赤橙黄绿青蓝紫、时间的今天明天后天
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
}
}
复制代码
然后创建对应语句的实例:
代码语言:javascript复制let sentences = [
// 使用%x%的语法来表示可变数据
new Sentence(['�mily%', '的', '�mily%', '叫', '是', '什么'], 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,以实现更好的功能,如:获取天气、获取地点、获取土味情话。 - 区分不同性格的回答,内向的人、外向的人、热情的人、冷淡的人拥有不一样的语气。
还有更多的想法就不一一列举了。
最后
感谢大家的阅读,此文仅为抛砖引玉,代码质量不佳请勿见怪。
(诚挚的眼神)
关于本文
作者:我系小西几呀
https://juejin.cn/post/6963781192428552205
代码语言:javascript复制