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

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
}