使用 Liferay 和 Lucene 实现企业门户智能帮助机器人

Tags: liferay lucene

企业门户智能帮助需求分析

众所周知,对于中大型企业而言,企业门户系统提供了一个了解企业的访问入口,既动态地发布企业新闻公告等企业信息展示,又集成企业 OA,ERP,BI 等各种业务系统,同时聚合企业的各种结构化与非结构化文档资料及商务业务知识。所有访问者包括企业员工、合作伙伴、客户及供应商都可以通过企业信息门户方便地获取自己所需的信息。

Portal 技术是业界实现企业门户的成熟稳定的平台框架,基于 Portal Web 实现的门户系统,可以通过 Portlet 的功能模块,独立完成业务系统的聚合,每个 Portlet 在 Web 页面上占用页面的一部分空间,提供页面的某一个特殊功能,展示及和其它的 Portlet 协作的任务等,例如,展示公司最新新闻公告、显示 OA 待办工单、用户日程管理,财务/人事/ERP 系统统一登录,等等。

关于 Portal 的技术细节本文不再累述,感兴趣的读者可以参考 Portal 技术平台官方网站 查阅。

传统企业门户检索的局限

随着企业的发展,在 Portal 门户上聚合的业务系统及各个 Portlet 功能越来越多,越来越复杂,如何有效地在分类繁多的企业门户上查找所需要的企业信息成为企业 IT 部门头痛的事情,传统的 Portal 门户上的信息查找通常是模糊查询和全文检索,然而由于门户系统上集成业务系统的异构性(MySQL/Oracle 数据库,Java/.NET 开发语言…etc)及企业内容(尤其是大量 word、pdf 等非结构化文档内容)的分散存储,检索出的内容往往与想要的结果大相径庭。

以笔者遇到的一个客户为例:客户为一个药品及器械制造企业(以下简称 XX 制药公司),业务系统有 OA,ERP,BI,HR,CRM 等 10 余个,客户使用 Liferay Portal 实现了企业门户。对于客户而言,由于制药企业涉及大量药品生产质量管理规范(GMP),药品生产企业许可证,药品生产企业生产范围全部剂型和品种表;申请认证范围剂和品种表(注明常年生产品种)等相关资料;以及制造业实施精细化管理所需要的营销业务,财务管理,人事规章流程制度等,新到职员工往往在企业门户上无所适从,对如何有效的检索和查找到应有的资料一筹莫展,且对于公司的营销,OA 办公,财务报销等流程十分茫然。

客户的门户搜索功能只能基于企业新闻,公文及文档资料等进行模糊查询和检索,使用效果很差。比如业务员在公司官网上搜索“SK2014 型氯化钠注射液有哪些经销商”,传统全文检索会找出公司新闻正文、标题、摘要等带有氯化钠或者经销商的众多无用信息,又或如新入职员工在公司门户上搜索“出差报销流程”,检索出大量报销的规则制度却很难找到向导式的 step by step 的指南及链接,最终只能求助于公司行政及客服人员。

智能帮助解决的问题

随着技术的发展和用户习惯的变化,传统企业门户以人工服务为主要特征的业务办理,信息查找等客服形态不断向自动化、智能化、人性化、多渠道的方向演进,企业门户智能帮助机器人解决方案应运而生,智能机器人一般是在企业门户界面上,通过一问一答的交互形式,智能理解、精确地定位员工或者客户所需要提问的知识(或业务信息),为员工提供个性化的信息服务、引导员工解决业务问题或者为员工提供便捷化的业务流程。

智能帮助机器人打造了企业门户系统的新功能,开创了一个无时无刻、随时服务的贴身顾问角色。企业内部员工可以得到及时有效的指导,快速的解决问题,快速的学习和得到培训和帮助;外部客户及合作伙伴可以准确的得到关于业务开展及该企业产品营销等方面的数据及资料;企业行政,财务,人力资源等服务部门可以从简单重复的帮助和解决问题的人工工作中解放出来,解放耗费在简单重复劳动上的劳动力。高效先进的智能客服平台,能显著提升服务效率和降低服务成本,有效改善用户体验和提高用户满意度,实现低成本高回报

回页首

企业门户智能帮助案例详解

本文我们就以上文中提及的 XX 制药客户为例,讲述在为该客户实施的项目实例中如何实现企业门户智能帮助机器人。

根据以上客户出现问题及需求分析,客户急需要在其 Portal 门户上实现类似在线客服的可根据上下文智能化解决业务人员问题的聊天机器人的功能。该智能机器人程序能够识别公司业务人员发送的自然语言并回复与之相关的内容,业务人员与机器人的对话往往限定在某个领域,该领域取决与业务人员的查询场景,比如公司销售人员与机器人的对话基本上都与公司产品的品种,介绍,价格等相关。

业务人员与机器人的对话方式为一问一答,但客户业务人员对话的连续性需求较高,例如:业务人员通常会询问产品价格后,继续询问公司的折扣范围,经销商信息等,因此机器人还需要能判断用户上一次的话题,将回答范围限定在特定的领域,缩短智能应答的响应时间,提高客服效率。

总体架构

机器人的总体架构思路是预先从门户的各种后台资源中采集大量的问答知识,这些资源包括公司新闻,公文公告,技术及标准文档,财务/人事制度等,有些资源是以 Portlet 方式发布在 Liferay 平台上(如 CMSArticle),有的是在文件系统中,如公司的 FTP 服务器上存放的 GMP 标准规范文档资料,有的是存放在数据库中,如财务/人事流程等。机器人定期收集这些资源后使用成熟的检索引擎技术进行索引创建,当收到业务人员在门户上的提问时,机器人通过特定的算法检索索引并找出与问题最贴切的答案,所以可以将机器人的实现分为主要两个部分:构建问答库,处理用户查询。其总体架构如下:

图 1. 企业门户智能帮助机器人总体架构图

问答库是机器人的基础,问答库中的记录数越多,涉及面越广,能够问答的问题就越多,为了满足业务人员对话连续性的需求,考虑加入一些日常寒暄,笑话,网络流行语等,让机器人响应起来更加智能化和人性化。问答库并不是一次性创建的,当 Liferay Portal 门户上的内容更新及新的资料上传时,问答库需要重新采集并触发索引重建,不断的迭代优化,这样才能保证机器人的问答是准确和实效的,且是自学习的。

创建问答库后,使用全文检索引擎定期从问答库中扫描提取每一条问题并进行分词,分词后建立索引,索引记录了在每一条问题中出现的次数和位置,当收到业务人员在公司门户上的问题时,也预先对问题进行分词预处理,然后从索引中检索包含这词的问题记录,再计算这些问题记录与用户问题的相似度,找出相似度最高的问题,再从问答库中查询出该问题的答案返回给业务人员。

业界处理分词索引及检索有成熟的全文检索引擎,能够帮助我们完成上文中提及的索引和检索两大部分工作,本次项目中采用业界优秀的 Lucene 开源框架。

Lucene 是 Apache 软件基金会 Jakarta 项目组的一个子项目,是一个开放源代码的全文检索引擎工具包,提供了完整的查询引擎,索引引擎及文本分析引擎。Lucene 提供了一个简单却强大的应用程式接口。是当前以及最近几年最受欢迎的免费 Java 信息检索程序库。使用它不仅可以构建具体的全文检索应用,而且可以以此为基础建立起完整的全文检索引擎,将之集成到各种系统比如企业门户中去,某些商业软件也采用了 Lucene 作为其内部全文检索子系统的核心。(比如 IBM 的 WebSphere 中也采用了 Lucene 做为全文检索),Lucene 以其开放源代码的特性、优异的索引结构、良好的系统架构获得了越来越多的应用。关于 Lucene 的详细开发本文不再累述,读者可参见 Lucene 官方网站 了解更多内容。

Liferay 中也自带全文检索 Lucene 的接口,可以对部分 Portlet 内容进行全文检索,但只能匹配出相似度高的公司新闻,文章及 Wiki 等文本信息,但对于数据库,FTP 文件服务器等资源的分词和检索,其没有提供相应的接口,因此我们考虑使用原生 Lucene API 对 Liferay Portlet 进行二次开发,封装为实现上文中的机器人的一个 Portlet 在企业门户上进行挂载。

系统设计

根据总体架构智能帮助机器人的各个组件的设计如下:

1:问答库设计

在设计问答库时,需要考虑以下需求:

  • 业务人员与机器人对话的内容可能是日常寒暄,可能是类似 iPhone Safari 的笑话消遣,也可能是咨询业务问题。

  • 对于日常寒暄,如果用户多次问同样的问题,要考虑回复不同的内容,因此要求问题和答案支持一对多的情况。

  • 如果业务人员在跟机器进行类似 iPhone Safari 的笑话聊天,当他发送“继续”,“再来一个”的时候,机器人要能理解用户的意思。

  • 如果业务人员提出的问题机器人无法回答,需要给出默认回复,如“你的问题好深奥啊,我回答不出呢”,此情况下应交给后台管理员人工处理。

基于以上考虑,问答库至少需要 4 张表,问答主表,答案分类子表,消遣笑话表和查询记录日志表。

问答主表用于存储所有问题,并对问题进行分类,Lucene 分词索引的主要来源即为该表的问题。

清单 1. 问答主表设计

mysql> describe knowledge;
+----------+---------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+----------+---------------+------+-----+---------+-------+
| id | int(11) | NO | PRI | NULL | |
| question | varchar(2000) | NO | | NULL | |
| answer | text | NO | | NULL | |
| category | int(11) | NO | | NULL | |
+----------+---------------+------+-----+---------+-------+


字段 quesiton 是提问问题,字段 answer 表示答案,category 字段为知识库类别,跟查询记录日志表里 category 类别是一致的。问题与答案可以是一对一关系(比如公司 FTP 服务器后台的 word,pdf 抽取,标题,作者及摘要插入问题字段,文本内容插入答案字段),也可以是一对多关系(如 ERP 中的业务资料,商品名为问题字段,订单号及经销商等信息为多个答案),一对多关系时,answer 字段为空字符串,具体多条答案存储在答案分表中,以 pid 做问题的父键关联答案分类子表存放问题主表对应问题的回答,主表与子表是 1 对多关系。

清单 2. 答案分类子表设计

mysql> describe knowledge_sub
 -> ;
+--------+---------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------+---------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| pid | int(11) | NO | | NULL | |
| answer | text | NO | | NULL | |
+--------+---------+------+-----+---------+----------------+


清单 3. 消遣休闲表设计

消遣笑话表主要存放一些幽默搞笑及网络段子

mysql> describe joke;
+--------------+--------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------------+--------+------+-----+---------+----------------+
| joke_id | int(8) | NO | PRI | NULL | auto_increment |
| joke_content | text | YES | | NULL | |
+--------------+--------+------+-----+---------+----------------+


日志表是所有查询及应答的详细记录日志,chat_category 为用户查询的类别,与 knowledgeknowage 表中 category 字段一致,如果机器人不能回答,则该日志记录的 chat_category 字段为 0,且 resp_msg 为空字符串,后台监控线程会定期扫描到该类日志信息触发后台进程,交给系统管理员进行人工处理。

清单 4. 会话日志表设计

mysql> describe chat_log;
+---------------+---------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+---------------+---------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| open_id | varchar(30) | NO | | NULL | |
| create_time | varchar(20) | NO | | NULL | |
| req_msg | varchar(2000) | NO | | NULL | |
| resp_msg | varchar(2000) | NO | | NULL | |
| chat_category | int(11) | YES | | NULL | |
+---------------+---------------+------+-----+---------+----------------+


2:问答库采集

问答库的采集主要利用 Lucene 进行索引创建,Lucene 是一个文本搜索引擎,因此来自客户 Portal 门户上的新闻,公告,后台 FTP 服务器上的公司资料,技术文献以及后台数据库中的业务信息都需要转换为文本形式。

对于 Portal 门户上的新闻公告等,可以通过 Liferay 标准 API 获取到新闻或者文档的正文从而进行索引,对于 FTP 文件服务器上的 pdf,word 等各种格式的数据源,可以通过 iText,POI 等开源框架进行解析并转换为文本,数据库中的业务信息比较方便,可以通过标准 JDBC 查询后获取其需要索引的内容,从而实现多数据源的统一索引创建和入库,其设计功能如下:

图 2. 问答库多数据源采集及统一索引架构图

对于客户 Portal 门户上的 Portlet 内容的获取并进行分词索引,客户使用的是 Liferay Portal,Liferay Portal 既是一个开源门户网站建设工具,同时也是一个基于 Java 架构的应用软件系统开发平台。它不仅具有强大的网站内容管理和基于文件的内容管理功能,而且还集成了协作套件、开放社交、应用开发、权限管理、工作流、知识库、规则引擎和搜索引擎等 J2EE 应用程序,值得各类门户网站建设人员和 Java 应用开发人员重点关注,详情参见 Liferay Portal 官网社区

Liferay 有针对 Portlet 内容的 API 接口,从而可以方便的实现企业门户中众多 Portlet 内容的抽取,例如客户存放新闻的类叫做 CmsArticle,在 Liferay 后台的 groupId 为 10012,我们通过 Liferay 的 API 查找新闻 Portlet,抽取其新闻标题和作者做为问答库的提问,抽取新闻正文做为问答库的答案,存入后台的 knowledgeknowaldge 库表中。抽取方式可以定期批量抽取所有的公司新闻,也可以抽取最新 add 的新闻。

存放在 ERP 数据库及 FTP 服务器上的其他客户非结构化数据的抽取及分词索引在本文中不再累述,有兴趣的读者可以参考本文提供示例代码中 util 包里的 WordUtilImpl,PdfUtilImpl 等相关代码。

系统实现

以上我们介绍了系统总体架构及设计,现在我们来看看该智能机器人在 Liferay Portal 上的具体实现。

问答库数据库操作

主要包括获取问答库的 knowledgeknowage 及 knowledgeknowage_sub 表的所有记录,获取 chat_log 的上一条聊天记录(上下文问答),获取 joke 表消遣笑话,保存聊天记录等核心数据库表操作,统一封装为 MySqlUtil 类中,其代码示例如下:

清单 5. 问答库操作类代码示例

public class MySQLUtil {
public static List<Knowledge> findAllKnowledge() {
    List<Knowledge> knowledgeList = new ArrayList<Knowledge>();
    String sql = "select * from knowledge";
    MySQLUtil mysqlUtil = new MySQLUtil();
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        conn = mysqlUtil.getConn();
        ps = conn.prepareStatement(sql);
        rs = ps.executeQuery();
        while (rs.next()) {
        Knowledge knowledge = new Knowledge();
        knowledge.setId(rs.getInt("id"));
        knowledge.setQuestion(rs.getString("question"));
        knowledge.setAnswer(rs.getString("answer"));
        knowledge.setCategory(rs.getInt("category"));
        knowledgeList.add(knowledge);
    }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        // 释放资源
        mysqlUtil.releaseResources(conn, ps, rs);
    }
    return knowledgeList;
}

public static int getLastCategory(String openId) {
    int chatCategory = -1;
    String sql = "select chat_category from chat_log where open_id=? order by id desc limit 0,1";
    
    MySQLUtil mysqlUtil = new MySQLUtil();
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        conn = mysqlUtil.getConn();
        ps = conn.prepareStatement(sql);
        ps.setString(1, openId);
        rs = ps.executeQuery();
        if (rs.next()) {
        chatCategory = rs.getInt("chat_category");
    }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        // 释放资源
        mysqlUtil.releaseResources(conn, ps, rs);
    }
    
    return chatCategory;
}

public static String getKnowledSub(int knowledgeId) {
    String knowledgeAnswer = "";
    String sql = "select answer from knowledge_sub where pid=? order by rand() limit 0,1";
    
    MySQLUtil mysqlUtil = new MySQLUtil();
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        conn = mysqlUtil.getConn();
        ps = conn.prepareStatement(sql);
        ps.setInt(1, knowledgeId);
        rs = ps.executeQuery();
        if (rs.next()) {
            knowledgeAnswer = rs.getString("answer");
        }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        // 释放资源
        mysqlUtil.releaseResources(conn, ps, rs);
    }
    return knowledgeAnswer;
}

/**
 * 随机获取一条笑话
 * 
 * @return String
 */
public static String getJoke() {
    String jokeContent = "";
    String sql = "select joke_content from joke order by rand() limit 0,1";
    
    MySQLUtil mysqlUtil = new MySQLUtil();
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        conn = mysqlUtil.getConn();
        ps = conn.prepareStatement(sql);
        rs = ps.executeQuery();
        if (rs.next()) {
            jokeContent = rs.getString("joke_content");
        }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        // 释放资源
        mysqlUtil.releaseResources(conn, ps, rs);
    }
    
    return jokeContent;
}
public static void saveChatLog(String openId, String createTime,
String reqMsg, String respMsg, int chatCategory) {
    String sql = "insert into chat_log(open_id, create_time, 
    req_msg, resp_msg, chat_category) values(?, ?, ?, ?, ?)";
    
    MySQLUtil mysqlUtil = new MySQLUtil();
    Connection conn = null;
    PreparedStatement ps = null;
    ResultSet rs = null;
    try {
        conn = mysqlUtil.getConn();
        ps = conn.prepareStatement(sql);
        ps.setString(1, openId);
        ps.setString(2, createTime);
        ps.setString(3, reqMsg);
        ps.setString(4, respMsg);
        ps.setInt(5, chatCategory);
        ps.executeUpdate();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        // 释放资源
        mysqlUtil.releaseResources(conn, ps, rs);
    }
}


代码解释:

saveChatLog 为门户会话的日志记录,openId 为企业用户的统一帐号,如果是游客默认 OpenID 为“guest”,createTime 会话的创建时间,reqMsg 为用户查询的消息,respMsg 是机器人回复的消息;

chatCategory 是聊天类别;

getJoke 获取一条笑话,用于与客户消遣时随机的应答;

getKnowledSub 是主要获取问答库答案的操作;

getLastCategory 用于获取上次的会话的类别,以便机器人识别会话的上下文从而进行同一类别的知识库检索。

问答库数据源抽取

由于涉及客户内部机密,这里选取 Liferay Portlet 的公司新闻公告做为问答库数据源的抽取示例,其他数据源如 FTP 服务器上的 GMP 标准规范(pdf 格式)等的待索引数据的抽取读者可以参考原型系统 Demo 项目包源代码下的 util 子包内的 PdfUtil,WordUtil 等代码。

所有 liferay portlet plugin 的项目可以使用其 Service Call 的 API 进行远程方式服务调用(跟 Portal 容器的 service 交互)。

所有 Service Call API 在官方具有 doc 可以查询,在每一类 Liferay Portal 的子 package 下对应的-ServiceUtil 即为该类实体 Service 操作的 API 类,如 com.liferay.portlet.journal.service 包下的 JournalArticleLocalServiceUtil 就是对 journal 类型门户新闻的 service 操作,具体可以参考 Liferay Portal SDK

本项目中采用 Service Call API 进行 Liferay Portal 门户上的公司新闻类作为源进行抽取,其新闻 title 及作者作为问答库问题字段以便索引,新闻全文作为 answer 字段应答,其代码示例如下:

清单 6.Liferay Portlet 源抽取代码示例

public class LiferayPortletUtil {
    private ArrayList<Knowledge> knowages = new ArrayList<Knowledge>();
    //将抽取的公司新闻内容存入存问答库以便 lucene 生产检索索引
    public void saveToDb(){
        for(Knowledge item:knowages){
            MySQLUtil.addKnowage(item);
        }
    }
    public void getGroupArticle(long groupId){
        try {
            List<JournalArticle> articles = 
            	JournalArticleLocalServiceUtil.getArticles(groupId);
            for(JournalArticle item:articles){
                Knowledge newKnowledge = new Knowledge();
                newKnowledge.setCategory(3);//公司新闻类别
                newKnowledge.setQuestion(item.getDescription());
                newKnowledge.setAnswer(item.getContent());
                knowages.add(newKnowledge);
            }
        } catch (SystemException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } 
    }
    
    public void getLatestArticle(long pkId){
        try {
            JournalArticle article = JournalArticleLocalServiceUtil.getLatestArticle(pkId);
            Knowledge newKnowledge = new Knowledge();
            newKnowledge.setCategory(3);//公司新闻类别
            newKnowledge.setQuestion(article.getDescription());
            newKnowledge.setAnswer(article.getContent());
            knowages.add(newKnowledge);
        } catch (SystemException | PortalException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } 
    }
}


代码解释:

getLatestArticle 按当前时间去获取最新 article,用于机器人检索最新公司新闻;

getGroupArticle 根据类别 Id 获取 Portlet 新闻,用于定期批量抽取公司新闻并更新问答库。

机器人核心服务类

机器人核心服务类主要作用为:

1:调用数据库 util 类获取问答知识表的所有问题,并调用 Lucene API 进行索引创建

2:从索引文件中建设匹配指定问题的问答知识

3:问答库内容更新后进行索引的重建

其示例代码如下:

清单 7. 机器人核心服务类代码示例

public class ChatService {
    public static String getIndexDir() {
        // 得到.class 文件所在路径(WEB-INF/classes/)
        String classpath = ChatService.class.getResource("/").getPath();
        // 将 classpath 中的%20 替换为空格
        classpath = classpath.replaceAll("%20", " ");
        // 索引存储位置:WEB-INF/classes/index/
        return classpath + "index/";
    }
    
    public static void createIndex() {
        List<Knowledge> knowledgeList = MySQLUtil.findAllKnowledge(); 
        Directory directory = null;
        IndexWriter indexWriter = null;
        try {
            directory = FSDirectory.open(new File(getIndexDir()));
            IndexWriterConfig iwConfig = new IndexWriterConfig(
            	Version.LUCENE_46, new IKAnalyzer(true));
            indexWriter = new IndexWriter(directory, iwConfig);
            Document doc = null;
            for (Knowledge knowledge : knowledgeList) {
                doc = new Document();
                // 对 question 进行分词存储
                doc.add(new TextField("question", knowledge.getQuestion(), Store.YES));
                doc.add(new IntField("id", knowledge.getId(), Store.YES));
                doc.add(new StringField("answer", knowledge.getAnswer(), Store.YES));
                doc.add(new IntField("category", knowledge.getCategory(), Store.YES));
                indexWriter.addDocument(doc);
            }
            
            indexWriter.close();
            directory.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    @SuppressWarnings("deprecation")
    private static Knowledge searchIndex(String content) {
        com.pojo.Knowledge knowledge = null;
        try {
            Directory directory = FSDirectory.open(new File(getIndexDir()));
            IndexReader reader = IndexReader.open(directory);
            IndexSearcher searcher = new IndexSearcher(reader);
            // 使用查询解析器创建 Query
            QueryParser questParser = new QueryParser(Version.LUCENE_46,
            	"question", new IKAnalyzer(true));
            Query query = questParser.parse(QueryParser.escape(content));
            // 检索得分最高的文档
            TopDocs topDocs = searcher.search(query, 1);
            if (topDocs.totalHits > 0) {
                knowledge = new Knowledge();
                ScoreDoc[] scoreDoc = topDocs.scoreDocs;
                for (ScoreDoc sd : scoreDoc) {
                    Document doc = searcher.doc(sd.doc);
                    knowledge.setId(doc.getField("id").numericValue().intValue());
                    knowledge.setQuestion(doc.get("question"));
                    knowledge.setAnswer(doc.get("answer"));
                    knowledge.setCategory(doc.getField("category").numericValue().intValue());
                }
            }
            reader.close();
            directory.close();
        } catch (Exception e) {
            knowledge = null;
            e.printStackTrace();
        }
        
        return knowledge;
    }
    
    public static String chat(String openId, String createTime, String question) {
        String answer = null;
        int chatCategory = 0;
        Knowledge knowledge = searchIndex(question);
        // 找到匹配项
        if (null != knowledge) {
            // 笑话
            if (5 == knowledge.getCategory()) {
                answer = MySQLUtil.getJoke();
                chatCategory = 5;
            }
            // 上下文
            else if (4 == knowledge.getCategory()) {
                // 判断上一次的聊天类别
                int category = MySQLUtil.getLastCategory(openId);
                // 如果是笑话,本次继续回复笑话给用户
            if (5 == category) {
                answer = MySQLUtil.getJoke();
                chatCategory = 5;
            } else {
                answer = knowledge.getAnswer();
                chatCategory = knowledge.getCategory();
            }
        }
        // 普通对话
        else {
            answer = knowledge.getAnswer();
            // 如果答案为空,根据知识 id 从问答知识分表中随机获取一条
            if ("".equals(answer))
                answer = MySQLUtil.getKnowledSub(knowledge.getId());
                chatCategory = 4;
            }
        }
        // 未找到匹配项
        else {
            answer = getDefaultAnswer();
            chatCategory = 0;
        }
        // 保存聊天记录
        MySQLUtil.saveChatLog(openId, createTime, question, answer, chatCategory);
        return answer;
    }
    
    
    /**
     * 随机获取一个默认的答案
     * 
     * @return
     */
    private static String getDefaultAnswer() {
        String []answer = {
        "要不我们聊点别的?",
        "恩?你到底在说什么呢?",
        "没有听懂你说的,能否换个说法?",
        "虽然不明白你的意思,但我却能用心去感受",
        "听的我一头雾水,阁下的知识真是渊博呀,膜拜~",
        "真心听不懂你在说什么,要不你换种表达方式如何?",
        "哎,我小学语文是体育老师教的,理解起来有点困难哦",
        "是世界变化太快,还是我不够有才?为何你说话我不明白?"
        };
        
        return answer[getRandomNumber(answer.length)];
    }
}


代码解释:

Chat 方法是机器人聊天的主要操作,根据用户提交的 question 返回 answer,openId 为用户的 OpenID,createTime 是会话的创建时间,question 存放用户查询的问题,answer 为返回用户的检索答案;

searchIndex 是调用 Lucene API 进行检索的操作,从建立的索引文件中根据问题检索出相应的答案,content 为检索的文本,Knowledge 为检索到匹配的 question 索引后,根据 question ID 找到的对应的回答;

createIndex 是创建索引的主要工作,首先取得问答知识库中的所有记录,然后遍历问答知识库创建索引,其中对问答记录中的 id、answer 和 category 不分词存储,对 question 进行分词并存储,做为机器人会话检索的基础。

LiferayPortlet 封装

由于客户门户系统采用 Liferay Portal 开发,因此该智能帮助机器人使用 Liferay Plugin Portlet 项目开发为一个 Portlet 在客户门户上挂载,具体如下:

清单 8.Liferay Portlet 代码示例

package com.ehdc;

public class HelpPortlet extends GenericPortlet { 

    private SimpleDateFormat format = new SimpleDateFormat("yyyy-mm-dd hh24:mi:ss");
    protected String editJSP;
    protected String viewJSP;
    
    public void init() {
        this.editJSP = getInitParameter("edit-jsp");
        this.viewJSP = getInitParameter("view-jsp");
        File indexDir = new File(ChatService.getIndexDir());
        // 如果索引目录不存在则创建索引
        if (!indexDir.exists())
            ChatService.createIndex();
    }
    
    protected void include(String path,RenderRequest request,RenderResponse response)
                                                throws PortletException, IOException {
         PortletRequestDispatcher prd = getPortletContext().getRequestDispatcher(path);
         if(prd == null){
             System.out.println(path+" is not a valid include");
         }else{
             prd.include(request, response);
         }
     }
    
    @Override
     protected void doDispatch(RenderRequest request, RenderResponse response)
                                             throws PortletException, IOException {
         String jspPage = request.getParameter("jspPage");
         if(jspPage != null){
             include(jspPage,request,response);
         }else{
             super.doDispatch(request, response);
         }
     }
    
    
    
    @Override 
    protected void doView(RenderRequest request, RenderResponse response) 
                                            throws PortletException, IOException { 
        include(this.viewJSP,request,response);
    } 
    
    @Override 
    protected void doEdit(RenderRequest request, RenderResponse response) 
                                            throws PortletException, IOException { 
        include(editJSP,request,response);
    } 
    
    @Override 
    public void processAction(ActionRequest request, ActionResponse response) 
                                            throws PortletException, IOException {
        HttpServletRequest httpReq = PortalUtil.getHttpServletRequest(request); 
        String question = request.getParameter("queryQuestion");
        String openId = (String)request.getPortletSession().getAttribute("openId");
        if(openId==null){
            openId = "guest";
        }
        Date now = new Date();
        String createTime =this.format.format(now);
        String answer = "";
        init();
        answer=ChatService.chat(openId, createTime, question);
        response.setRenderParameter("answer", answer);
        response.setRenderParameter("queryQuestion", question);
        System.out.println("processAction method"); 
    } 
    
    @Override 
    public void render(RenderRequest request, RenderResponse response) 
                                            throws PortletException, IOException { 
        this.doView(request, response); 
    } 
}


代码解释:

init 方法获取 Portlet 初始页面,初始化 Lucene 索引;

processAction 是 Portlet 处理机器人会话的核心操作,首先通过 ActionRequest 获取 Portlet 页面用户咨询问题及提问人员 openId(非公司业务人员默认为 guest 用户),然后初始化 Lucene 后台(如果还没有初始化的话),准备检索消息,接着调用核心服务类 chatService 处理检索并接受返回结果,最后在 ActionResponse 中向 Portlet 前台写 Lucene 检索结果信息, 同时保留上次提问问题。

开发的 Portlet 需要在 Liferay 中进行配置,其 portlet.xml 配置文件如下:

清单 9:portlet.xml 配置文件

<?xml version="1.0"?>
<portlet-app xmlns="http://java.sun.com/xml/ns/portlet/portlet-app_2_0.xsd"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/portlet/portlet-app_2_0.xsd
    http://java.sun.com/xml/ns/portlet/portlet-app_2_0.xsd"
    version="2.0">
        <portlet>
            <portlet-name>help</portlet-name>
            <display-name>智能帮助</display-name>
            <portlet-class>com.ehdc.HelpPortlet</portlet-class>
            <init-param>
                <name>view-jsp</name>
                <value>/html/help/display.jsp</value>
                </init-param>
                <init-param>
                <name>edit-jsp</name>
                <value>/html/help/query.jsp</value>
            </init-param>
            <expiration-cache>0</expiration-cache>
            <supports>
                <mime-type>text/html</mime-type>
                <portlet-mode>view</portlet-mode>
                <portlet-mode>edit</portlet-mode>
            </supports>
            <portlet-info>
                <title>Help</title>
                <short-title>Help</short-title>
                <keywords></keywords>
            </portlet-info>
            <security-role-ref>
                <role-name>administrator</role-name>
            </security-role-ref>
            <security-role-ref>
                <role-name>guest</role-name>
            </security-role-ref>
            <security-role-ref>
                <role-name>power-user</role-name>
            </security-role-ref>
            <security-role-ref>
                <role-name>user</role-name>
            </security-role-ref>
        </portlet>
</portlet-app>


上文中所配置的 com.ehdc.HelpPortlet 为本项目的 Portlet 实现类,Portlet 配置 view 及 edit 两个模式,分别对应机器人初始及接受查询界面的 query.jsp 和机器人返回咨询应答的会话界面的 display.jsp,

在客户的 Portal 门户分类目录中我们将其放入示例的 category 中,其 liferay-portlet.xml 配置文件如下:

清单 10:liferay-portlet.xml 配置文件

<?xml version="1.0"?>
<!DOCTYPE display PUBLIC "-//Liferay//DTD Display 6.2.0//EN"
	"http://www.liferay.com/dtd/liferay-display_6_2_0.dtd">
<display>
    <category name="category.sample">
        <portlet id="help"></portlet>
    </category>
</display>


初始及接受用户咨询的 query.jsp 页面代码示例如下:

清单 11:query.jsp 代码示例

<%@ page language="java" contentType="text/html;charset=gbk"%>
<portlet:defineObjects />
<% 
RenderResponse renderRespons = (RenderResponse) request.getAttribute("javax.portlet.response");
 PortletURL actionURL = renderRespons.createActionURL();
 
 %>
<form id="queryRobertForm" method="post" action="<%=actionURL.toString() %>">

<div class="box3">
 <span class="jt">
 <span class="jt">
 <p>你好,我是 XXX 公司机器人小科,请问您要咨询什么:</p>
 </span>
 </span>
</div>
 
 <div class="box2">
 <span class="jt">
 <span class="jt2">
<p>
<input type="text" name="queryQuestion" />
<a href="#" onclick="document.getElementById('queryRobertForm').submit();">咨询</a>
</p>
</span>
</span>
</div>
</form>


返回咨询结果会话 display.jsp 页面代码示例如下:

清单 12:display.jsp 代码示例

<%@ taglib uri="http://java.sun.com/portlet" prefix="portlet"%> 
<%@ page language="java" contentType="text/html;charset=gbk" %> 
 
<portlet:defineObjects/> 
<% 
 String question = request.getParameter("queryQuestion");
 String answer = request.getParameter("answer"); 
%>
<div class="box2">
 <span class="jt"><span class="jt2">我:<%=question%></span></span>
</div>
 
<div class="box3">
 <div class="jt">小科:<%=answer%></div>
</div>


以上 Liferay Plugin 项目基于 Liferay Portal SDK 6.2 gaymen 版本开发,部署在客户的 Portal 门户服务器(tomcat7)的同一台主机上。

部署后的企业门户智能机器人的问答效果如下:

图 3. 智能帮助原型 Demo 效果图(1)

图 4. 智能帮助原型 Demo 效果图(2)

图 5. 智能帮助原型 Demo 效果图(3)

图 6. 智能帮助原型 Demo 效果图(4)

回页首

结束语

文章首先描述了传统企业门户内容检索遇到的问题,然后通过分析提出智能帮助机器人能够进行解决的设计方案。该方案以 Liferay Portal 和 Lucene 搭建智能帮助机器人为技术架构,并通过客户的实际项目案例详细讲解了系统设计及具体的代码实现。本文适用于熟悉 Liferay 开源门户框架及 Lucene 索引检索引擎编程的读者,旨在帮助读者快速开始使用 Liferay 和 Lucene 进行探索和开发应用。文中的 Demo 代码经过微小修改即可用于用户企业门户智能帮助的实际生产环境。

回页首

下载

描述名字大小
代码示例sample.zip 92k

本文链接:http://www.4byte.cn/learning/119801/shi-yong-liferay-he-lucene-shi-xian-qi-ye-men-hu-zhi-neng-bang-zhu-ji-qi-ren.html