You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
400 lines
16 KiB
400 lines
16 KiB
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"yunyan/comm"
|
|
"yunyan/lego/core"
|
|
"yunyan/lego/core/cbase"
|
|
"yunyan/lego/sys/mysql"
|
|
"yunyan/pb"
|
|
"yunyan/utils"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
/*
|
|
会议纪要的 MCP 工具:让 EMAI 能回答「我上次开会定了啥」「上个月关于XX的会议说了什么」。
|
|
|
|
## 为什么做成 MCP 工具,而不是握手时把纪要塞进会话
|
|
|
|
塞进会话只能在 Start 那一刻做,那时用户还没开口,不知道他要问什么——结果是
|
|
闲聊、问天气也要白查一遍,还平白占掉上下文。做成工具则由模型自己判断该不该调:
|
|
问天气就不碰它,问会议才查一次。
|
|
|
|
## 为什么不能靠「知识库按 user_id 标签过滤」来隔离
|
|
|
|
实测过(2026-09-07):multimodal-dialog 的 Start 指令里,`biz_params` 只认
|
|
`user_defined_params` / `user_prompt_params` / `user_query_params` 三个子字段,
|
|
塞 `rag_options` 进去服务端**静默丢弃**——对照实验里塞一个纯属虚构的字段
|
|
`__no_such_field_zzz__`,服务端同样回 Started,两者待遇完全一样。
|
|
也就是说对话链路上**没有任何入口**能告诉知识库「只准检索这个用户的文件」。
|
|
给文件打 uid 标签是能做到的,但没人会去看那个标签。
|
|
|
|
所以过滤必须落在我们自己这一侧:本工具的 uid 来自 Authorization 里的 JWT
|
|
(见 auth.go 的 ResolveUID),模型传什么参数都不作数,跨用户读不到。
|
|
|
|
## 只读
|
|
|
|
不提供修改/删除。写路径在 home 的 echomeet 模块里,在 MCP 复制一份必然漂移。
|
|
|
|
## 检索方案:现在是「自家 MySQL + ngram 全文索引」,不是向量检索
|
|
|
|
数据一直在 `echomeet_record.summary` 里,**没有上传到百炼知识库**。每次调用现查现喂。
|
|
这么选是因为:隔离是硬的(uid 从我们签的令牌解出,不依赖百炼任何配置)、
|
|
会议内容不出库、改检索逻辑不用重灌数据、也不用维护入库那套异步流程和 FileId 映射。
|
|
|
|
三级检索,逐级回退(见 Handl):全文索引(相关度排序) → LIKE(兜单字和索引缺失) → 最近几条。
|
|
|
|
⚠️ **已知上限:这是词面匹配,不是语义匹配。**
|
|
问「关于降本增效的会议」,纪要里写的是「成本优化」,ngram 依然匹配不到。
|
|
全文索引解决的是「多关键词、词序不同、按相关度排序」,解决不了同义改写。
|
|
|
|
**什么时候该换成向量/知识库方案**(2026-09-07 与用户明确约定:先做这版,量大了再说):
|
|
出现下面任一条就该重新评估,别等它自己变好——
|
|
1. 单用户会议记录数上千,`MATCH` 扫描明显变慢(现在全库才几十条);
|
|
2. 真机上反复出现「问得对但查不到」,且原因是同义词而不是关键词提取;
|
|
3. 需要跨会议做归纳(「今年关于 A 项目一共讨论过几次」),
|
|
这类问题靠取 3 条纪要喂给模型答不了。
|
|
换的时候优先考虑:纪要入百炼知识库打 uid 标签,**检索仍由本工具发起**
|
|
(调百炼 Retrieve 接口 + SearchFilters 按 uid 过滤),这样隔离强度与现在一致。
|
|
*/
|
|
|
|
// 单条纪要塞给模型的最大字符数。整篇纪要可达数千字,
|
|
// 一次命中三五条就足以把上下文顶满,模型反而抓不住重点。
|
|
const meetingSummaryMaxRunes = 1200
|
|
|
|
// 关键词最多几个。模型偶尔会把整句话拆成十几个词,
|
|
// 每个都 LIKE 一遍在 MySQL 上是全表扫描。
|
|
const meetingMaxKeywords = 5
|
|
|
|
type tool_search_meeting_notes struct {
|
|
cbase.ModuleCompBase
|
|
module *Mcp
|
|
}
|
|
|
|
func (this *tool_search_meeting_notes) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) {
|
|
this.ModuleCompBase.Init(service, module, comp, opt)
|
|
this.module = module.(*Mcp)
|
|
return
|
|
}
|
|
|
|
func (this *tool_search_meeting_notes) Start() (err error) {
|
|
err = this.ModuleCompBase.Start()
|
|
this.module.AddTool(ToolGroup_GLOBAL, this.Tool(), this.Handl)
|
|
return
|
|
}
|
|
|
|
func (this *tool_search_meeting_notes) Tool() mcp.Tool {
|
|
return mcp.NewTool("search_meeting_notes",
|
|
mcp.WithDescription("查询用户自己的会议纪要。用户问到「上次开会」「会议上定了什么」"+
|
|
"「关于某个项目的会议」这类问题时调用。不要用它回答与会议无关的问题。"),
|
|
mcp.WithString("keywords",
|
|
mcp.Description("检索关键词,多个用空格分隔;留空则按时间返回最近的会议"),
|
|
),
|
|
mcp.WithString("start_date", mcp.Description("起始日期 YYYY-MM-DD,可省略")),
|
|
mcp.WithString("end_date", mcp.Description("结束日期 YYYY-MM-DD,可省略")),
|
|
mcp.WithNumber("limit",
|
|
mcp.Description("返回几条会议(默认3,最大5)。纪要很长,条数多了模型抓不住重点"),
|
|
mcp.DefaultNumber(3),
|
|
),
|
|
// uid 只为兼容后台已配好的工具定义,传上来只会拿去和会话 uid 比对
|
|
mcp.WithString("uid", mcp.Description("用户ID(可省略,服务端以登录身份为准)")),
|
|
)
|
|
}
|
|
|
|
func (this *tool_search_meeting_notes) Handl(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
uid, err := this.module.ResolveUIDWithToken(ctx,
|
|
request.GetString("uid", ""), request.GetString("auth_token", ""))
|
|
if err != nil {
|
|
// ⚠️ 校验失败时把**收到的参数名**记下来(只记名字,不记值——值里就是令牌本身)。
|
|
// 「百炼把 user_defined_params 交过来时到底长什么形状」文档没写死,
|
|
// 只说「透传给 agent 和 mcp 服务」。没有这行日志,一旦它不是按
|
|
// auth_token 这个键传过来,现象就只是「身份校验失败」,无从下手。
|
|
// ⚠️ 用 safeLogErrorf(全局 log)而不是 this.module.Warnf:
|
|
// 实测模块自带的 Warnf 在 mcp 这个服务里既不进 stdout 也不进 log/mcp.log,
|
|
// 排查时等于没有。core.go 里那条工具异常日志用的就是这个。
|
|
safeLogErrorf("search_meeting_notes 身份校验失败: %v;收到的参数名=%v",
|
|
err, argNames(request.GetRawArguments()))
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
limit := int(request.GetInt("limit", 3))
|
|
if limit <= 0 || limit > 5 {
|
|
limit = 3
|
|
}
|
|
|
|
startTs, _ := parseMeetingDay(request.GetString("start_date", ""), false)
|
|
endTs, _ := parseMeetingDay(request.GetString("end_date", ""), true)
|
|
keywords := splitMeetingKeywords(request.GetString("keywords", ""))
|
|
|
|
// ⚠️ 三条检索路径(无关键词 / 全文 / LIKE / 回退)必须共用同一套过滤条件,
|
|
// 所以一律从 recentQuery 起手。这里原先另拼了一份一模一样的条件,
|
|
// 结果是「给 recentQuery 加一道门槛」只对其中三条生效,无关键词那条照旧漏过去。
|
|
tx := this.recentQuery(uid, startTs, endTs)
|
|
|
|
records := make([]*pb.DBEchoMeetRecord, 0, limit)
|
|
matched := "" // 实际用了哪种检索方式,只为日志和返回值里说明
|
|
|
|
if len(keywords) == 0 {
|
|
if e := tx.Order("creationtime DESC").Limit(limit).Find(&records).Error; e != nil {
|
|
safeLogErrorf("search_meeting_notes 查询失败: %v", e)
|
|
return mcp.NewToolResultError("查询失败"), nil
|
|
}
|
|
matched = "recent"
|
|
} else {
|
|
// 先走全文索引(相关度排序),失败或没命中再退回 LIKE。
|
|
// ⚠️ 这两级不能省:
|
|
// - 索引可能还没建出来(老部署、权限不足),MATCH 会直接报错;
|
|
// - ngram_token_size=2 时**单字关键词进不了索引**,只有 LIKE 找得到。
|
|
var ftErr error
|
|
records, ftErr = this.searchFulltext(uid, startTs, endTs, keywords, limit)
|
|
if ftErr != nil {
|
|
safeLogErrorf("search_meeting_notes 全文检索不可用,退回 LIKE: %v", ftErr)
|
|
} else if len(records) > 0 {
|
|
matched = "fulltext"
|
|
}
|
|
if len(records) == 0 {
|
|
likeTx := this.recentQuery(uid, startTs, endTs)
|
|
for _, kw := range keywords {
|
|
// like 参数化拼装,别手拼 SQL
|
|
like := "%" + kw + "%"
|
|
likeTx = likeTx.Where("(title LIKE ? OR summary LIKE ? OR overview LIKE ?)",
|
|
like, like, like)
|
|
}
|
|
records = records[:0]
|
|
if e := likeTx.Order("creationtime DESC").Limit(limit).Find(&records).Error; e != nil {
|
|
safeLogErrorf("search_meeting_notes LIKE 查询失败: %v", e)
|
|
} else if len(records) > 0 {
|
|
matched = "like"
|
|
}
|
|
}
|
|
}
|
|
|
|
// ⚠️ 关键词一条都没命中时,回退成「按时间取最近几条」。
|
|
//
|
|
// 用户问得越自然,模型越容易把整句话塞进 keywords ——「最近的会议纪要」
|
|
// 里的「最近」在纪要正文中根本不会出现,LIKE 必然全落空,
|
|
// 用户看到的就是助手说「查不到您的会议纪要」,而库里明明有。
|
|
// 真机上就是这么发生的(一次调用返回 total=0)。
|
|
//
|
|
// 回退结果里带 fallback 标记,让模型知道这不是精确匹配,
|
|
// 别把「最近一次会议」当成「你问的那次会议」来回答。
|
|
fellBack := false
|
|
if len(records) == 0 && len(keywords) > 0 {
|
|
records = records[:0]
|
|
if e := this.recentQuery(uid, startTs, endTs).Order("creationtime DESC").
|
|
Limit(limit).Find(&records).Error; e != nil {
|
|
safeLogErrorf("search_meeting_notes 回退查询失败: %v", e)
|
|
} else if len(records) > 0 {
|
|
fellBack = true
|
|
matched = "recent"
|
|
}
|
|
}
|
|
|
|
out := map[string]interface{}{
|
|
"result": "success",
|
|
"total": len(records),
|
|
"meetings": briefMeetings(records),
|
|
}
|
|
if matched != "" {
|
|
out["matched_by"] = matched
|
|
}
|
|
if fellBack {
|
|
out["fallback"] = "关键词未命中,以下是该用户最近的会议,供参考"
|
|
}
|
|
if len(records) == 0 {
|
|
out["hint"] = "该用户目前没有已完成总结的会议记录"
|
|
}
|
|
return mcp.NewToolResultText(utils.ToString(out)), nil
|
|
}
|
|
|
|
// recentQuery 不带关键词的「最近会议」查询,与主查询共用同一套过滤条件
|
|
// (只看本人、只看总结完成的、够长的)。
|
|
//
|
|
// ⚠️ 光靠 CHAR_LENGTH(summary) 挡不住垃圾纪要:内容极薄时模型按模板兜底规则
|
|
// 照样能写出六百多字,一条 6 秒的测试通话就能挤进检索结果、把真会议顶掉。
|
|
// 所以另加一道时长门槛,口径与拾忆的待办抽取同一个常量。
|
|
func (this *tool_search_meeting_notes) recentQuery(uid string, start, end int64) *gorm.DB {
|
|
tx := mysql.Table(comm.TableEchomeetRecord).
|
|
Where("uid = ?", uid).
|
|
// 只找总结真的做完、且有实质内容的。
|
|
// ⚠️ 不能只判 `summary <> ''`:真机库里存在 summary 只有 2 个字符的记录
|
|
// (极短录音总结出来的残次品)。它照样能通过非空判断,占掉 limit 的名额,
|
|
// 把真正有内容的会议挤出去。
|
|
Where("CHAR_LENGTH(summary) >= ?", 20).
|
|
Where("seconds >= ?", comm.MeetingMinSeconds)
|
|
// 时间范围:记录上没有「会议实际发生日期」这个字段,只能用 creationtime
|
|
// (= 录音上传时间)。补录旧录音时会偏,这是已知偏差,不是这里能修的。
|
|
if start > 0 {
|
|
tx = tx.Where("creationtime >= ?", start)
|
|
}
|
|
if end > 0 {
|
|
tx = tx.Where("creationtime <= ?", end)
|
|
}
|
|
return tx
|
|
}
|
|
|
|
// argNames 只取参数名,不碰值。排查「令牌到底以什么键名传过来」用。
|
|
func argNames(raw any) []string {
|
|
m, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// briefMeetings 只挑模型答题用得上的字段。
|
|
// 绝不要把整行记录丢给模型——里面有音频地址、任务 id、服务商 id 这些它用不上
|
|
// 也不该看到的东西,还会把上下文撑爆。
|
|
func briefMeetings(records []*pb.DBEchoMeetRecord) []map[string]interface{} {
|
|
out := make([]map[string]interface{}, 0, len(records))
|
|
for _, r := range records {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
item := map[string]interface{}{
|
|
"title": r.Title,
|
|
"date": comm.FormatMemoryDate(time.Unix(r.Creationtime, 0)),
|
|
"summary": truncateRunes(r.Summary, meetingSummaryMaxRunes),
|
|
}
|
|
if s := strings.TrimSpace(r.Overview); s != "" {
|
|
item["overview"] = truncateRunes(s, meetingSummaryMaxRunes)
|
|
}
|
|
// ⚠️ personnel 在真机上多数是 `[Speaker_0] [Speaker_1]` 这种占位标签,
|
|
// 不是真名字。原样喂给模型只是噪声,还占上下文——只在用户真的改过
|
|
// 说话人名字时才带上。
|
|
if v := meaningfulPersonnel(r.Personnel); v != "" {
|
|
item["personnel"] = v
|
|
}
|
|
// 用户创建记录时写的备注,是会议背景的一部分,对答题有用
|
|
if v := strings.TrimSpace(r.Remark); v != "" {
|
|
item["remark"] = truncateRunes(v, 300)
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// meaningfulPersonnel 过滤掉「全是 [Speaker_N] 占位标签」的情况。
|
|
// 只要有一段不是占位标签,就认为用户命名过,整串原样返回。
|
|
func meaningfulPersonnel(raw string) string {
|
|
v := strings.TrimSpace(raw)
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
for _, f := range strings.Fields(v) {
|
|
f = strings.Trim(f, "[]")
|
|
if f == "" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(f, "Speaker_") {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// truncateRunes 按**字符**而不是字节截断。按字节切会把一个汉字劈成两半,
|
|
// 拼出非法 UTF-8,下游 json 编码要么报错要么变成乱码。
|
|
func truncateRunes(s string, max int) string {
|
|
rs := []rune(s)
|
|
if len(rs) <= max {
|
|
return s
|
|
}
|
|
return string(rs[:max]) + "…(内容较长已截断)"
|
|
}
|
|
|
|
// splitMeetingKeywords 把模型给的关键词串拆成词,去空、去重、限量。
|
|
func splitMeetingKeywords(raw string) []string {
|
|
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
|
return r == ' ' || r == '\t' || r == '\n' || r == ',' || r == ',' || r == '、'
|
|
})
|
|
seen := make(map[string]bool, len(fields))
|
|
out := make([]string, 0, meetingMaxKeywords)
|
|
for _, f := range fields {
|
|
f = strings.TrimSpace(f)
|
|
if f == "" || seen[f] {
|
|
continue
|
|
}
|
|
seen[f] = true
|
|
out = append(out, f)
|
|
if len(out) >= meetingMaxKeywords {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseMeetingDay 把 YYYY-MM-DD 转成 unix 秒。endOfDay=true 时取当天 23:59:59,
|
|
// 否则取 00:00:00 —— 只写 end_date 而按 00:00:00 比较,会把那一天整天都排除掉。
|
|
//
|
|
// ⚠️ 用的是容器本地时区(Asia/Shanghai),与 memory 模块口径一致。
|
|
// 海外用户的「今天」会偏,这是全项目的既有取舍,不在这里单独处理。
|
|
func parseMeetingDay(s string, endOfDay bool) (int64, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
d, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
if endOfDay {
|
|
d = d.Add(24*time.Hour - time.Second)
|
|
}
|
|
return d.Unix(), true
|
|
}
|
|
|
|
// fulltextColumns 必须与 echomeet 建索引时的列**逐字一致**,
|
|
// 否则 MySQL 报 "Can't find FULLTEXT index matching the column list"。
|
|
// 建索引处:modules/echomeet/model.go 的 recordFulltextColumns。
|
|
const fulltextColumns = "title,summary,overview,remark"
|
|
|
|
// searchFulltext 走 ngram 全文索引,按相关度排序。
|
|
//
|
|
// 用 BOOLEAN MODE + 每个关键词加引号:
|
|
// - 加引号是为了**转义**——boolean 模式里 + - > < ( ) ~ * @ 都是操作符,
|
|
// 模型传来的自然语言里随时可能带上,不引起来轻则语义跑偏、重则语法报错;
|
|
// - 不加 `+` 前缀(即 OR 语义)是刻意的:用户问「关于降本增效的会议」,
|
|
// 模型常把整句拆成好几个词,要求全部命中(AND)会把召回压到 0,
|
|
// 而全文索引本来就会按命中数和词频打分排序,OR + 排序的效果更好。
|
|
func (this *tool_search_meeting_notes) searchFulltext(
|
|
uid string, startTs, endTs int64, keywords []string, limit int,
|
|
) ([]*pb.DBEchoMeetRecord, error) {
|
|
terms := make([]string, 0, len(keywords))
|
|
for _, kw := range keywords {
|
|
// ngram_token_size=2:单字进不了索引,交给 LIKE 那一级去兜
|
|
if len([]rune(kw)) < 2 {
|
|
continue
|
|
}
|
|
terms = append(terms, `"`+strings.ReplaceAll(kw, `"`, ``)+`"`)
|
|
}
|
|
if len(terms) == 0 {
|
|
return nil, nil
|
|
}
|
|
expr := strings.Join(terms, " ")
|
|
|
|
match := "MATCH(" + fulltextColumns + ") AGAINST (? IN BOOLEAN MODE)"
|
|
records := make([]*pb.DBEchoMeetRecord, 0, limit)
|
|
err := this.recentQuery(uid, startTs, endTs).
|
|
Where(match, expr).
|
|
// ⚠️ 排序必须用 gorm.Expr 把 expr 当参数传。字符串拼接的 Order(...) 带不了参数,
|
|
// 只能拼出 AGAINST ('')——相关度恒为 0,排序静默失效:看起来在按相关度排,
|
|
// 实际是乱序,而且不会有任何报错。
|
|
Order(gorm.Expr(match+" DESC", expr)).
|
|
Order("creationtime DESC").
|
|
Limit(limit).
|
|
Find(&records).Error
|
|
return records, err
|
|
}
|
|
|