Browse Source
EMAI 助手此前答不了「我上次开会讲了什么」——纪要在服务端,助手拿不到。 补 search_meeting_notes 工具,配套解决身份怎么传。 身份链路(关键取舍): 百炼是第三方,MCP 请求经它转发,所以不能把网关那把能开全站的 JWT 交出去。 新增 MCP 专用令牌:user_getmcptoken 由 home 签发(uid 从网关解出的会话取, 客户端说了不算),aud=mcp、TTL 2h,用独立的 MCP_TOKEN_KEY 签。 即使泄漏,能做的也只有读这一个用户的只读数据,且两小时后失效。 ⚠️ 裸 uid 参数已不再被信任:谁都能填别人的 uid。ResolveUIDWithToken 要么拿到 Authorization 里的登录态,要么拿到这张令牌,两者都没有就直接拒。 auth_mcptoken_test.go 守着这条底线(含 aud 不符、过期、换密钥三种伪造)。 ⚠️ 令牌必须按工具名嵌套传:真机实测百炼**不会**把 biz_params.user_defined_params 平铺进工具参数——那次调用服务端只收到 [limit]。按 {"工具名": {"auth_token": ...}} 嵌套才生效。这个形状文档没写死,是试出来的。 检索是三级回退:全文索引(按相关度排序) → LIKE(兜单字,ngram_token_size=2 时 单字进不了索引) → 最近几条。最后一级不能省:模型常把「最近的会议纪要」整句 当关键词传,纪要正文里不会有「最近」二字,LIKE 全落空,用户看到的就是 「查不到您的会议纪要」而库里明明有。回退结果带 fallback 标记,免得模型把 「最近一次会议」当成「你问的那次会议」来答。 文件头注释里记了三个「该换向量/知识库方案」的触发条件,量大了照着判断。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>main
16 changed files with 958 additions and 64 deletions
@ -0,0 +1,138 @@ |
|||||
|
package mcp |
||||
|
|
||||
|
import ( |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/golang-jwt/jwt/v5" |
||||
|
) |
||||
|
|
||||
|
// 签发在 user 模块、校验在 mcp 模块,两边隔着一个进程,
|
||||
|
// 写错了不会编译报错,只会在真机上静默拒绝(或更糟:静默放行)。
|
||||
|
// 这组用例把两边的口径钉在一起。
|
||||
|
|
||||
|
const testMcpKey = "test-mcp-key" |
||||
|
|
||||
|
// signLikeUserModule 复刻 user/api_getmcptoken.go 的签发方式。
|
||||
|
// ⚠️ 改那边的 claims 结构时,这里要一起改,否则这组用例就失去意义了。
|
||||
|
func signLikeUserModule(t *testing.T, uid, audience string, ttl time.Duration, key string) string { |
||||
|
t.Helper() |
||||
|
claims := &jwt.RegisteredClaims{ |
||||
|
ID: uid, |
||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), |
||||
|
IssuedAt: jwt.NewNumericDate(time.Now()), |
||||
|
} |
||||
|
if audience != "" { |
||||
|
claims.Audience = jwt.ClaimStrings{audience} |
||||
|
} |
||||
|
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(key)) |
||||
|
if err != nil { |
||||
|
t.Fatalf("签发失败: %v", err) |
||||
|
} |
||||
|
return s |
||||
|
} |
||||
|
|
||||
|
func TestParseUIDWithAudience(t *testing.T) { |
||||
|
const uid = "2095078768153460736" |
||||
|
|
||||
|
t.Run("正常令牌能解出uid", func(t *testing.T) { |
||||
|
tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) |
||||
|
got, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience) |
||||
|
if err != nil { |
||||
|
t.Fatalf("不该失败: %v", err) |
||||
|
} |
||||
|
if got != uid { |
||||
|
t.Fatalf("uid 不对: got=%s want=%s", got, uid) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("换了密钥必须拒", func(t *testing.T) { |
||||
|
tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, "another-key") |
||||
|
if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { |
||||
|
t.Fatal("别人签的令牌居然通过了") |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("用途不符必须拒", func(t *testing.T) { |
||||
|
// 这条守的是「将来若有别处也用 McpTokenKey 签东西,别被 MCP 一并接受」
|
||||
|
tok := signLikeUserModule(t, uid, "someother", time.Hour, testMcpKey) |
||||
|
if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { |
||||
|
t.Fatal("用途不符的令牌居然通过了") |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("没有用途标记必须拒", func(t *testing.T) { |
||||
|
tok := signLikeUserModule(t, uid, "", time.Hour, testMcpKey) |
||||
|
if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { |
||||
|
t.Fatal("没有 aud 的令牌居然通过了") |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("过期必须拒", func(t *testing.T) { |
||||
|
tok := signLikeUserModule(t, uid, mcpTokenAudience, -time.Minute, testMcpKey) |
||||
|
if _, err := parseUIDWithAudience(tok, []byte(testMcpKey), mcpTokenAudience); err == nil { |
||||
|
t.Fatal("过期令牌居然通过了") |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("没有过期时间必须拒", func(t *testing.T) { |
||||
|
// 没有 exp 的令牌等同长期有效,这套「短时效」的前提就没了
|
||||
|
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.RegisteredClaims{ |
||||
|
ID: uid, |
||||
|
Audience: jwt.ClaimStrings{mcpTokenAudience}, |
||||
|
}).SignedString([]byte(testMcpKey)) |
||||
|
if err != nil { |
||||
|
t.Fatalf("签发失败: %v", err) |
||||
|
} |
||||
|
if _, err := parseUIDWithAudience(s, []byte(testMcpKey), mcpTokenAudience); err == nil { |
||||
|
t.Fatal("没有 exp 的令牌居然通过了") |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Bearer前缀要能兼容", func(t *testing.T) { |
||||
|
tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) |
||||
|
if _, err := parseUIDWithAudience("Bearer "+tok, []byte(testMcpKey), mcpTokenAudience); err != nil { |
||||
|
t.Fatalf("带 Bearer 前缀不该失败: %v", err) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func TestResolveUIDWithToken(t *testing.T) { |
||||
|
const uid = "2095078768153460736" |
||||
|
m := &Mcp{} |
||||
|
m.options = &Options{McpTokenKey: testMcpKey} |
||||
|
|
||||
|
t.Run("参数令牌可作为身份来源", func(t *testing.T) { |
||||
|
tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) |
||||
|
got, err := m.ResolveUIDWithToken(t.Context(), "", tok) |
||||
|
if err != nil { |
||||
|
t.Fatalf("不该失败: %v", err) |
||||
|
} |
||||
|
if got != uid { |
||||
|
t.Fatalf("uid 不对: %s", got) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("参数uid与令牌不符必须拒", func(t *testing.T) { |
||||
|
tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) |
||||
|
if _, err := m.ResolveUIDWithToken(t.Context(), "9999", tok); err == nil { |
||||
|
t.Fatal("冒充别人居然通过了") |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("只给裸uid不给令牌必须拒", func(t *testing.T) { |
||||
|
// 这条是整层鉴权的底线:裸 uid 谁都能填,绝不能作为身份来源
|
||||
|
if _, err := m.ResolveUIDWithToken(t.Context(), uid, ""); err == nil { |
||||
|
t.Fatal("只凭一个 uid 参数就放行了") |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("没配密钥时带令牌也要拒", func(t *testing.T) { |
||||
|
empty := &Mcp{} |
||||
|
empty.options = &Options{} |
||||
|
tok := signLikeUserModule(t, uid, mcpTokenAudience, time.Hour, testMcpKey) |
||||
|
if _, err := empty.ResolveUIDWithToken(t.Context(), "", tok); err == nil { |
||||
|
t.Fatal("密钥没配却放行了") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,401 @@ |
|||||
|
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 |
||||
|
} |
||||
@ -0,0 +1,107 @@ |
|||||
|
package user |
||||
|
|
||||
|
import ( |
||||
|
"time" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/pb" |
||||
|
|
||||
|
"github.com/golang-jwt/jwt/v5" |
||||
|
) |
||||
|
|
||||
|
/* |
||||
|
签发 MCP 访问令牌。 |
||||
|
|
||||
|
## 为什么需要它 |
||||
|
|
||||
|
EMAI 助手在百炼那边调我们的 MCP 工具(查会议纪要、查待办…)时,MCP 必须知道 |
||||
|
「这是哪个用户」。但这条链路上没有任何一处能天然带上身份: |
||||
|
|
||||
|
- 客户端连百炼只带**百炼的 API Key**,没有把用户自己的 JWT 交出去; |
||||
|
- 百炼调 MCP 时的 `Authorization` 头是控制台里**配死的**,所有用户共用一个值。 |
||||
|
|
||||
|
唯一可行的通道是 Start 指令的 `biz_params.user_defined_params`——协议文档明确说 |
||||
|
它是「透传给 agent 和 mcp 服务的参数」,会作为工具参数交到 MCP 手里。 |
||||
|
|
||||
|
⚠️ 实测确认过(2026-09-07):往 `biz_params` 里塞 `rag_options` 之类协议没定义的 |
||||
|
字段,服务端**静默丢弃**(对照组塞一个纯属虚构的字段,服务端同样回 Started), |
||||
|
所以只能用协议已有的这三个字段,不能自创。 |
||||
|
|
||||
|
## 为什么不直接用登录 JWT |
||||
|
|
||||
|
因为这条路要经过百炼(第三方)。登录 JWT 等价于账号的全部权限,一旦落到日志里 |
||||
|
或被第三方留存,用户的一切接口都能被调用。本令牌**只用于 MCP、且短时效**, |
||||
|
即使泄露,能做的也只有读该用户的会议纪要/待办这类只读工具。 |
||||
|
|
||||
|
## 为什么用独立密钥而不是 gateway 的 TokenKey |
||||
|
|
||||
|
同一把密钥签出来的令牌,gateway 也会认——那就等于又造了一把万能钥匙, |
||||
|
「限定用途」这件事就白做了。所以用 `McpTokenKey`,MCP 侧用同一个值校验, |
||||
|
gateway 不认识它。 |
||||
|
*/ |
||||
|
|
||||
|
// mcpTokenTTL 令牌有效期。
|
||||
|
//
|
||||
|
// 取 2 小时是个折中:太短会让一次长对话中途失效(客户端得在会话里重签、
|
||||
|
// 而 Start 的参数是握手时定死的,改不了);太长则削弱了「短时效」本身的意义。
|
||||
|
// 客户端应在过期前重新申请,别等报错再补。
|
||||
|
const mcpTokenTTL = 2 * time.Hour |
||||
|
|
||||
|
// mcpTokenAudience 令牌用途标记。MCP 侧必须校验它,否则将来若有别的地方
|
||||
|
// 也用 McpTokenKey 签东西,那些令牌会被 MCP 一并接受。
|
||||
|
const mcpTokenAudience = "mcp" |
||||
|
|
||||
|
// @Summary 申请 MCP 访问令牌
|
||||
|
// @Description 签发短时效、仅限 MCP 工具调用的令牌,供客户端放进百炼会话的 biz_params 透传
|
||||
|
// @Tags User
|
||||
|
// @Accept json
|
||||
|
// @Produce json
|
||||
|
// @Security BearerAuth
|
||||
|
// @Param user body pb.UserGetMcpTokenReq true "请求数据"
|
||||
|
// @Success 200 {object} comm.HttpResult{data=pb.UserGetMcpTokenResp} "响应数据"
|
||||
|
// @Router /api/home/user_getmcptoken [post]
|
||||
|
func (this *apiComp) GetMcpToken(session comm.IUserSession, req *pb.UserGetMcpTokenReq) (resp *pb.UserGetMcpTokenResp, errdata *pb.ErrorData) { |
||||
|
uid := session.GetUserId() |
||||
|
if uid == "" { |
||||
|
errdata = &pb.ErrorData{ |
||||
|
Code: pb.ErrorCode_NoLogin, |
||||
|
Message: "未登录", |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
key := this.module.options.McpTokenKey |
||||
|
if key == "" { |
||||
|
// 没配就直说,不要签一个空密钥的令牌。
|
||||
|
// 空密钥签出来的东西谁都能伪造,比功能不可用严重得多。
|
||||
|
errdata = &pb.ErrorData{ |
||||
|
Code: pb.ErrorCode_SystemError, |
||||
|
Message: "服务端未配置 McpTokenKey", |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
expiresAt := time.Now().Add(mcpTokenTTL) |
||||
|
// 与 gateway/MCP 同一套口径:uid 放在 RegisteredClaims 的 ID 字段。
|
||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.RegisteredClaims{ |
||||
|
ID: uid, |
||||
|
Audience: jwt.ClaimStrings{mcpTokenAudience}, |
||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt), |
||||
|
IssuedAt: jwt.NewNumericDate(time.Now()), |
||||
|
}) |
||||
|
signed, err := token.SignedString([]byte(key)) |
||||
|
if err != nil { |
||||
|
this.module.Errorf("GetMcpToken uid:%s 签发失败 err:%v", uid, err) |
||||
|
errdata = &pb.ErrorData{ |
||||
|
Code: pb.ErrorCode_SystemError, |
||||
|
Message: "令牌签发失败", |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
resp = &pb.UserGetMcpTokenResp{ |
||||
|
Token: signed, |
||||
|
Expiresat: expiresAt.Unix(), |
||||
|
} |
||||
|
return |
||||
|
} |
||||
Loading…
Reference in new issue