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", "")) tx := mysql.Table(comm.TableEchomeetRecord).Where("uid = ?", uid) // 只找总结真的做完、且有实质内容的。 // ⚠️ 不能只判 `summary <> ''`:真机库里存在 summary 只有 2 个字符的记录 // (极短录音总结出来的残次品)。它照样能通过非空判断,占掉 limit 的名额, // 把真正有内容的会议挤出去。 tx = tx.Where("CHAR_LENGTH(summary) >= ?", 20) // 时间范围:记录上没有「会议实际发生日期」这个字段,只能用 creationtime // (= 录音上传时间)。补录旧录音时会偏,这是已知偏差,不是这里能修的。 if startTs > 0 { tx = tx.Where("creationtime >= ?", startTs) } if endTs > 0 { tx = tx.Where("creationtime <= ?", 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 不带关键词的「最近会议」查询,与主查询共用同一套过滤条件 // (只看本人、只看总结完成的、时间范围)。 func (this *tool_search_meeting_notes) recentQuery(uid string, start, end int64) *gorm.DB { tx := mysql.Table(comm.TableEchomeetRecord). Where("uid = ?", uid). Where("CHAR_LENGTH(summary) >= ?", 20) 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 }