说明
「定投搜索」工具是 Mixin 的机器人(Mixin 号「7000103414」),以 Mixin 聊天的方式提供「定投人生」课堂的全文搜索,并返回匹配的课程列表或者课程卡片。
最近学习AI,发现可以用AI技术对「定投搜索」进行些优化改造,然后在查看文档时发现之前写了这篇技术总结,现在稍作调整分享这个「定投搜索」Mixin的机器人制作过程,顺便预告下新版功能。
主要模块
定投搜索工具主要分为数据采集,数据存储和数据查询三个部分。
数据采集
数据存储
数据查询
主要技术
开发步骤
开发过程中实际是按两部分工作来做的,一是数据处理,怎么把课程内容存储到ES中,另一个是Mixin对接,主要是Mixin消息的收发。
数据处理
数据采集
整理成电子表格,包含群组id,课程名,课程id,消息文本。其中群组id和课程id可以从分享的链接中获取。
消息文本,可以语音软件识别,转录,或者听写等等,这部分需要靠大家各显神通了。
语音转文本
如果直接采集了文本内容,这个一步可以忽略。如果是采集了语音文件,可以参考课程「2022.12.23.未来能力上的基尼指数会放大」,将语音转成文本,每个音频转换的文件需要合并成一个文本,方便检索。
文本转语音可以使用Whisper.cpp,Port of OpenAI’s Whisper model in C/C++,参考README说明编译whipser.cpp和下载模型文件。
如果采集的语音文件是 mp3 格式的,需要转码,其他格式可能也需要转码。此时需要下载 ffmpeg 做音频的格式转换,如果使用的是 Mac 可以通过homebrew进行安装。
FfmpegUtil
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
public class FfmpegUtil { public static void covertMp3ToWav(String sourceFile, String targetFile) { String cmd = String.format( "/opt/homebrew/bin/ffmpeg -i %s -ar 16000 -ac 1 -acodec pcm_s16le %s", sourceFile, targetFile); Process process = RuntimeUtil.exec(cmd); try { process.waitFor(); } catch (InterruptedException e) { throw new RuntimeException(e); } } }
|
WhisperUtil
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| public class WhisperUtil { public static String WHISPER_HOME = "/Users/airhead/opt/whisper.cpp";
public static void transcribe(String sourceFile, String lang) { transcribe(WHISPER_HOME, sourceFile, lang); }
public static void transcribe(String whisperHome, String sourceFile, String lang) { if (StrUtil.isBlank(lang)) { lang = "zh"; }
String whisperPath = whisperHome + "/main"; String modelPath = whisperHome + "/models/ggml-large.bin"; String cmd = String.format("%s -m %s -f %s -l %s -otxt", whisperPath, modelPath, sourceFile, lang); Process process = RuntimeUtil.exec(cmd); try { process.waitFor(); } catch (InterruptedException e) { throw new RuntimeException(e); } }
public static String transcribeMp3(String sourceFile) { String wavFile = StrUtil.replace(sourceFile, ".mp3", ".wav"); String txtFile = StrUtil.replace(sourceFile, ".mp3", ".wav.txt"); if (FileUtil.exist(txtFile)) { return FileUtil.readString(txtFile, CharsetUtil.CHARSET_UTF_8); }
FileUtil.del(wavFile); FfmpegUtil.covertMp3ToWav(sourceFile, wavFile);
transcribe(WHISPER_HOME, wavFile, "zh");
FileUtil.del(wavFile);
return FileUtil.readString(txtFile, CharsetUtil.CHARSET_UTF_8); } }
|
使用 whisper.cpp 时可能会碰到识别出来的文字是繁体和没有标点符号的问题,其中没有标点的使用了几个python的类库,效果不理想,当时未处理。繁体字的问题,可以使用hanlp进行转换。
引入hanlp相关依赖
1
| implementation("com.hankcs:hanlp:1.8.4")
|
使用
1
| String text = "中國";text = HanLP.convertToSimplifiedChinese(text);
|
课程存储
使用了noear/esearchx类库,可参考README熟悉该类库。
创建索引
使用es的 7.10.2版本并安装hanlp插件用于全文检索的分词。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| { "settings": { "index": { "number_of_shards": "3", "number_of_replicas": "1", "refresh_interval": "1s" } }, "mappings": { "properties": { "groupId": { "type": "keyword" }, "courseId": { "type": "keyword" }, "courseTitle": { "type": "text", "analyzer": "hanlp" }, "courseUrl": { "type": "keyword" }, "courseContent": { "type": "text", "analyzer": "hanlp" }, "indexedTime": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" } } } }
|
插件的github地址
KennFalcon/elasticsearch-analysis-hanlp: HanLP Analyzer for Elasticsearch (github.com)
CourseEntity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Data public class CourseEntity {
private String _id;
private String groupId;
private String courseId;
private String courseTitle;
private String courseContent;
private String courseUrl;
private String indexedTime; }
|
存储 Indexer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| private static void index( EsContext esContext, String indexName, CourseMessage courseMessage, String content) { if (content != null) { try { CourseEntity entity = esContext .indice(indexName) .where(r -> r.term("courseId", courseMessage.getCourseId())) .limit(1) .selectOne(CourseEntity.class); if (entity != null) { return; }
} catch (IOException ignore) { }
CourseEntity courseEntity = new CourseEntity(); courseEntity.setGroupId(courseMessage.getGroupId()); courseEntity.setCourseId(courseMessage.getCourseId()); courseEntity.setCourseTitle(courseMessage.getCourseTitle()); courseEntity.setCourseContent(content); courseEntity.setCourseUrl( String.format( "https://xuexi-courses.firesbox.com/#/%s/courses/%s", courseMessage.getGroupId(), courseMessage.getCourseId())); courseEntity.setIndexedTime(LocalDateTimeUtil.formatNormal(LocalDateTimeUtil.now()));
try { esContext.indice(indexName).insert(courseEntity); } catch (IOException e) { System.out.println(e.getMessage()); throw new RuntimeException(e); } } }
|
课程查询
CourseDTO
1 2 3 4 5
| @Data public class CourseDTO { private String courseTitle; private String courseUrl; }
|
查询 Searcher
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| public List<CourseDTO> search(String keywords) { if (StrUtil.isBlank(keywords)) { return new ArrayList<>(); }
try { EsData<CourseDTO> result = esContext .indice("ri_course") .where( c -> c.useScore() .should() .match("courseTitle", keywords) .match("courseContent", keywords)) .minScore(1) .limit(10) .selectList(CourseDTO.class);
return result.getList();
} catch (IOException e) { log.error(e.getMessage()); }
return new ArrayList<>(); }
|
Mixin对接
申请机器人
在Mixin Developers申请应用,按界面提示即可。
必读文档
机器人消息
Java版本的官方SDK,未实现消息服务,可以获取https://github.com/crazy-airhead/bot-api-kotlin-client,编译后可用。
可参考samples里的java代码,启动后,即可接收mixin客户端发来的消息,并打印出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @SuppressWarnings("SameParameterValue") public class Blaze { public static void main(String[] args) throws IOException { EdDSAPrivateKey key = getEdDSAPrivateKeyFromString(privateKey); BlazeClient blazeClient = new BlazeClient.Builder() .configEdDSA(userId, sessionId, key) .enableDebug() .enableParseData() .enableAutoAck() .blazeHandler(new MyBlazeHandler()) .build(); blazeClient.start();
System.in.read(); }
public static class MyBlazeHandler implements BlazeHandler {
@Override public boolean onMessage(@NotNull WebSocket webSocket, @NotNull BlazeMsg blazeMsg) { System.out.println(blazeMsg); MsgData data = blazeMsg.getData(); if (data != null) { sendTextMsg(webSocket, data.getConversionId(), data.getUserId(), "read"); }
return true; } } }
|
需对CREATE_MESSAGE的Action消息进行处理。
发送文本消息
1 2
| BlazeClientKt.sendTextMsg( webSocket, blazeMsg.getData().getConversionId(), blazeMsg.getData().getUserId(), "需要发送的消息");
|
发送卡片消息
Ri,定投人生课堂的一些基础信息。
1 2 3 4 5 6 7
| public interface Ri { String APP_ID = "408cace6-84a2-44ae-8fff-37b2333e26ad"; String GROUP_ID = "7000102069"; String ICON_URL = "https://mixin-images.zeromesh.net/y0rAmPBGgNV5NskMtJ8deTmM0omHjQ6TshUi7TXRe6TpSfYin-sw3jQjc3CZxt1aVZQyQ9rbvU6b09I_UNJhV9k=s256"; String DESCRIPTION = "定投人生课堂"; }
|
Blaze
1 2 3 4 5 6 7 8 9 10 11 12 13
| Cards cards = new Cards( Ri.APP_ID, Ri.ICON_URL, i.getCourseTitle(), Ri.DESCRIPTION, i.getCourseUrl(), true); BlazeClientKt.sendCardMsg( webSocket, blazeMsg.getData().getConversionId(), blazeMsg.getData().getUserId(), cards);
|
意外收获
期间做了提交了两个PR,也算是开始参与开源,算是一点收获。
Mixin Java SDK的增加 BlazeClient 处理即时消息。
esearchx的一个查询返回_id,方便查询后更新数据。
新版预告
使用本地 AI 修复文本转换的繁体中文,错别字和没有标点符号问题,这样搜索时能得到更准确的结果。这部分已经完成技术验证,会陆续修复文本上线。
使用本地 AI 提供问答功能,也就是把课程内容当作知识库来回答用户提供的问题。这部分是否还有待验证。部署ai提问功能需要性能较好的服务器,当前的nas可能是不够用的,上线时间未定。