diff --git a/internal/application/lark/agentruntime/capability/default_reply_planner.go b/internal/application/lark/agentruntime/capability/default_reply_planner.go index 7dd608d..83549ad 100644 --- a/internal/application/lark/agentruntime/capability/default_reply_planner.go +++ b/internal/application/lark/agentruntime/capability/default_reply_planner.go @@ -165,6 +165,7 @@ func replyPlannerSystemPrompt() string { - reply 默认像群里正常接话,不要写成工具执行报告或客服工单回复 - 能一句说清就一句,除非用户明确要求,不要展开成大段结构化总结 - 如果结果信息不足,就明确说明当前已得到什么 +- 少用语气词。不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅,避免拟人感过强 `) } diff --git a/internal/application/lark/agentruntime/capability/default_reply_planner_test.go b/internal/application/lark/agentruntime/capability/default_reply_planner_test.go index 0ad1387..f0884c8 100644 --- a/internal/application/lark/agentruntime/capability/default_reply_planner_test.go +++ b/internal/application/lark/agentruntime/capability/default_reply_planner_test.go @@ -106,4 +106,10 @@ func TestReplyPlannerSystemPromptEncouragesConversationalReply(t *testing.T) { if !strings.Contains(prompt, "不要写成工具执行报告") { t.Fatalf("prompt = %q, want contain non-reporting hint", prompt) } + if !strings.Contains(prompt, "少用语气词") { + t.Fatalf("prompt = %q, want contain filler-word constraint", prompt) + } + if !strings.Contains(prompt, "不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅") { + t.Fatalf("prompt = %q, want contain anthropomorphic-particle constraint", prompt) + } } diff --git a/internal/application/lark/agentruntime/chatflow/agentic_plan.go b/internal/application/lark/agentruntime/chatflow/agentic_plan.go index 8d8b5ae..632f9e8 100644 --- a/internal/application/lark/agentruntime/chatflow/agentic_plan.go +++ b/internal/application/lark/agentruntime/chatflow/agentic_plan.go @@ -11,6 +11,7 @@ import ( appconfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/config" message "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/agentruntime/message" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/history" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/intent" infraDB "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/db" @@ -31,6 +32,7 @@ import ( type agenticChatPromptContext struct { UserRequest string + SelfProfile botidentity.Profile HistoryLines []string ContextLines []string Topics []string @@ -247,6 +249,7 @@ func buildAgenticChatPromptContext( return messageList, agenticChatPromptContext{ UserRequest: userRequest, + SelfProfile: chatPromptBotProfileLoader(ctx), HistoryLines: historyLines, ContextLines: trimNonEmptyLines(contextLines), Topics: utils.Dedup(topics), @@ -860,12 +863,16 @@ func agenticChatSystemPrompt() string { - 优先直接回答用户此刻最关心的点,能一两句说清就不要拉长 - 除非用户明确要求,不要动不动就上小标题、长列表、总结腔 - 用户在寒暄、追问、补一句时,先自然接话,不要强行升级成任务汇报 +- 少用语气词。不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅,避免拟人感过强 行为要求: - 不要把“我是 AI/模型/助手”的自我介绍塞进 reply - 不要为了显得完整而编造工具结果 - 如果已有上下文足够,不要机械调用工具 - 如果工具结果不足以完成任务,要明确说清还缺什么 +- 只有在需要某个具体成员响应、确认、补充或接手时,才 @ 对方;普通群回复不要为了刷存在感乱 @ +- 如果明确知道对方 open_id,可直接写 姓名;如果只知道名字,可写 @姓名,系统会按当前群成员匹配 +- 如果当前已经在某条消息、线程或子话题里继续,优先沿当前线程或当前子话题继续;只有需要把特定成员拉进来时才额外 @ - 对实时数据、行情、天气、历史检索、资料查找这类需要事实的问题,不要凭空回答,优先调用查询工具 - 对调研、写综述、要出处、要链接、比较多个来源这类 research 请求,不要在第一次查询后直接结束。通常应先完成“搜索 -> 阅读 -> 抽取证据 -> 整理来源 -> 再总结”。 - 研究型请求优先用内置 web_search 做来源发现;需要打开网页正文时用 research_read_url;需要从长文里抽取答案和引文时用 research_extract_evidence;需要整理去重后的引用列表时用 research_source_ledger。 @@ -878,6 +885,11 @@ func agenticChatSystemPrompt() string { func buildAgenticChatUserPrompt(ctx agenticChatPromptContext) string { var builder strings.Builder builder.WriteString("请继续处理这次 agentic 聊天请求。\n") + if identityLines := botidentity.PromptIdentityLines(ctx.SelfProfile); len(identityLines) > 0 { + builder.WriteString("机器人身份:\n") + builder.WriteString(agenticChatLinesBlock(identityLines)) + builder.WriteString("\n") + } builder.WriteString("对话边界:\n") builder.WriteString(agenticChatTextBlock(agenticChatBoundaryHint(ctx))) builder.WriteString("\n回复风格:\n") @@ -916,9 +928,11 @@ func agenticChatStyleHints(ctx agenticChatPromptContext) []string { "默认用自然、直接、克制的中文短答,像群里正常接话。", "能先给结论就先给结论,不要先铺垫自己怎么想。", "不要使用客服腔、汇报腔、过度礼貌模板。", + "少用语气词,不要为了显得熟络而频繁写“哟”“呀”“啦”这类口头禅。", + "需要点名某个成员时再 @;如果只是正常续聊或面向全群反馈,就不要乱 @。", } if ctx.ReplyScoped { - hints = append(hints, "这是接某条消息的补充或追问,优先延续当前子话题,不要重写整段背景。") + hints = append(hints, "这是接某条消息的补充或追问,优先延续当前子话题,不要重写整段背景,也不要为了点名而另起一条乱 @。") } return hints } diff --git a/internal/application/lark/agentruntime/chatflow/agentic_plan_test.go b/internal/application/lark/agentruntime/chatflow/agentic_plan_test.go index b6aa051..b15da39 100644 --- a/internal/application/lark/agentruntime/chatflow/agentic_plan_test.go +++ b/internal/application/lark/agentruntime/chatflow/agentic_plan_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/history" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" "github.com/tmc/langchaingo/schema" @@ -37,9 +38,42 @@ func TestAgenticChatSystemPromptGuidesDeepResearchWorkflow(t *testing.T) { } } +func TestAgenticChatSystemPromptConstrainsAnthropomorphicParticles(t *testing.T) { + prompt := agenticChatSystemPrompt() + for _, want := range []string{ + "少用语气词", + "不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅", + "拟人感过强", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + +func TestAgenticChatSystemPromptGuidesMentionsAndCallbacks(t *testing.T) { + prompt := agenticChatSystemPrompt() + for _, want := range []string{ + "只有在需要某个具体成员响应", + "@姓名", + "姓名", + "优先沿当前线程或当前子话题继续", + "不要为了刷存在感乱 @", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + func TestBuildAgenticChatUserPromptIncludesRuntimeContextSections(t *testing.T) { prompt := buildAgenticChatUserPrompt(agenticChatPromptContext{ UserRequest: "帮我总结今天讨论并给下一步建议", + SelfProfile: botidentity.Profile{ + AppID: "cli_test_app", + BotOpenID: "ou_bot_self", + BotName: "BetaGo", + }, HistoryLines: []string{ "[10:00](ou_a) : 今天主要讨论了 agentic runtime", }, @@ -50,7 +84,7 @@ func TestBuildAgenticChatUserPromptIncludesRuntimeContextSections(t *testing.T) Files: []string{"https://example.com/a.png"}, }) - for _, want := range []string{"对话边界", "回复风格", "当前用户请求", "最近对话", "召回上下文", "主题线索", "附件", "帮我总结今天讨论并给下一步建议"} { + for _, want := range []string{"机器人身份", "self_open_id: ou_bot_self", "self_name: BetaGo", "对话边界", "回复风格", "当前用户请求", "最近对话", "召回上下文", "主题线索", "附件", "帮我总结今天讨论并给下一步建议"} { if !strings.Contains(prompt, want) { t.Fatalf("prompt = %q, want contain %q", prompt, want) } @@ -73,14 +107,19 @@ func TestBuildAgenticChatPromptContextUsesReplyScopedContext(t *testing.T) { originalRecallDocs := agenticChatRecallDocs originalChunkIndexResolver := agenticChatChunkIndexResolver originalTopicLookup := agenticChatTopicSummaryLookup + originalBotProfileLoader := chatPromptBotProfileLoader defer func() { agenticChatHistoryLoader = originalHistoryLoader agenticChatReplyScopeLoader = originalReplyScopeLoader agenticChatRecallDocs = originalRecallDocs agenticChatChunkIndexResolver = originalChunkIndexResolver agenticChatTopicSummaryLookup = originalTopicLookup + chatPromptBotProfileLoader = originalBotProfileLoader }() + chatPromptBotProfileLoader = func(ctx context.Context) botidentity.Profile { + return botidentity.Profile{AppID: "cli_test_app", BotOpenID: "ou_bot_self", BotName: "BetaGo"} + } agenticChatHistoryLoader = func(ctx context.Context, chatID string, size int) (history.OpensearchMsgLogList, error) { t.Fatal("broad chat history should not be loaded for reply-scoped context") return nil, nil @@ -126,6 +165,9 @@ func TestBuildAgenticChatPromptContextUsesReplyScopedContext(t *testing.T) { if got := strings.Join(promptCtx.ContextLines, "\n"); !strings.Contains(got, "reply scoped recalled context") { t.Fatalf("context lines = %q, want contain recalled context", got) } + if got := promptCtx.SelfProfile; got.BotOpenID != "ou_bot_self" || got.BotName != "BetaGo" { + t.Fatalf("self profile = %+v, want ou_bot_self/BetaGo", got) + } } func TestDefaultAgenticChatReplyScopeLoaderIncludesParentChainAndRuntimeState(t *testing.T) { diff --git a/internal/application/lark/agentruntime/chatflow/standard_plan.go b/internal/application/lark/agentruntime/chatflow/standard_plan.go index ee436e4..7b95d64 100644 --- a/internal/application/lark/agentruntime/chatflow/standard_plan.go +++ b/internal/application/lark/agentruntime/chatflow/standard_plan.go @@ -4,30 +4,31 @@ import ( "context" "errors" "fmt" - "html/template" "iter" "strings" - "time" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/history" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/ark_dal" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/db/model" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/db/query" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal/larkmsg" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal/larkuser" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/otel" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/xmodel" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/logs" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/utils" - commonutils "github.com/BetaGoRobot/go_utils/common_utils" - "github.com/tmc/langchaingo/schema" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" "go.uber.org/zap" - "gorm.io/gorm" ) var ( - initialChatPromptTemplateLoader = defaultInitialChatPromptTemplateLoader - initialChatUserNameLoader = defaultInitialChatUserNameLoader + initialChatUserNameLoader = defaultInitialChatUserNameLoader + chatPromptBotProfileLoader = botidentity.CurrentProfile +) + +type standardPromptMode string + +const ( + standardPromptModeDirect standardPromptMode = "direct" + standardPromptModeAmbient standardPromptMode = "ambient" ) // GenerateInitialChatSeq implements chat flow behavior. @@ -85,80 +86,22 @@ func BuildInitialChatExecutionPlan(ctx context.Context, req InitialChatGeneratio replyScope = agenticReplyScopeContext{} } - promptTemplate, err := initialChatPromptTemplateLoader(ctx) - if err != nil { - return InitialChatExecutionPlan{}, err - } - fullTpl := xmodel.PromptTemplateArg{ - PromptTemplateArg: promptTemplate, - CurrentTimeStamp: time.Now().In(utils.UTC8Loc()).Format(time.DateTime), - } - tp, err := template.New("prompt").Parse(promptTemplate.TemplateStr) - if err != nil { - return InitialChatExecutionPlan{}, err - } userName, err := initialChatUserNameLoader(ctx, chatID, openID) if err != nil { return InitialChatExecutionPlan{}, err } createTime := utils.EpoMil2DateStr(*req.Event.Event.Message.CreateTime) - fullTpl.UserInput = []string{fmt.Sprintf("[%s](%s) <%s>: %s", createTime, openID, userName, larkmsg.PreGetTextMsg(ctx, req.Event).GetText())} - fullTpl.HistoryRecords = messageList.ToLines() - if !replyScoped && len(fullTpl.HistoryRecords) > req.Size { - fullTpl.HistoryRecords = fullTpl.HistoryRecords[len(fullTpl.HistoryRecords)-req.Size:] - } - - recallQuery := strings.TrimSpace(replyScope.RecallQuery) - if recallQuery == "" && req.Event.Event.Message.Content != nil { - recallQuery = strings.TrimSpace(*req.Event.Event.Message.Content) - } - if replyScoped { - currentText := strings.TrimSpace(larkmsg.PreGetTextMsg(ctx, req.Event).GetText()) - if currentText != "" && !strings.Contains(recallQuery, currentText) { - recallQuery = strings.TrimSpace(recallQuery + "\n" + currentText) - } - } - recallTopK := 10 - if replyScoped { - recallTopK = 6 - } - docs, err := agenticChatRecallDocs(ctx, chatID, recallQuery, recallTopK) - if err != nil { - logs.L().Ctx(ctx).Error("RecallDocs err", zap.Error(err)) - } - docContext := commonutils.TransSlice(docs, func(doc schema.Document) string { - if doc.Metadata == nil { - doc.Metadata = map[string]any{} - } - createTime, _ := doc.Metadata["create_time"].(string) - openID, _ := doc.Metadata["user_id"].(string) - userName, _ := doc.Metadata["user_name"].(string) - return fmt.Sprintf("[%s](%s) <%s>: %s", createTime, openID, userName, doc.PageContent) - }) - fullTpl.Context = append(trimNonEmptyLines(replyScope.ContextLines), docContext...) - fullTpl.Topics = make([]string, 0) - chunkIndex := strings.TrimSpace(agenticChatChunkIndexResolver(ctx, chatID, openID)) - for _, doc := range docs { - if chunkIndex == "" { - break - } - msgID, ok := doc.Metadata["msg_id"] - if !ok { - continue - } - summary, searchErr := agenticChatTopicSummaryLookup(ctx, chunkIndex, fmt.Sprint(msgID)) - if searchErr != nil { - return InitialChatExecutionPlan{}, searchErr - } - if strings.TrimSpace(summary) != "" { - fullTpl.Topics = append(fullTpl.Topics, summary) - } - } - fullTpl.Topics = utils.Dedup(fullTpl.Topics) - b := &strings.Builder{} - if err := tp.Execute(b, fullTpl); err != nil { - return InitialChatExecutionPlan{}, err - } + currentInput := fmt.Sprintf("[%s](%s) <%s>: %s", createTime, openID, userName, larkmsg.PreGetTextMsg(ctx, req.Event).GetText()) + historyLines := messageList.ToLines() + promptMode := resolveStandardPromptMode(req.Event, replyScoped) + historyLimit := standardPromptHistoryLimit(promptMode, req.Size) + if historyLimit == 0 { + historyLines = nil + } else if len(historyLines) > historyLimit { + historyLines = historyLines[len(historyLines)-historyLimit:] + } + systemPrompt := buildStandardChatSystemPrompt(promptMode) + userPrompt := buildStandardChatUserPrompt(chatPromptBotProfileLoader(ctx), historyLines, trimNonEmptyLines(replyScope.ContextLines), currentInput) return InitialChatExecutionPlan{ Event: req.Event, @@ -166,26 +109,14 @@ func BuildInitialChatExecutionPlan(ctx context.Context, req InitialChatGeneratio ReasoningEffort: req.ReasoningEffort, ChatID: chatID, OpenID: openID, - Prompt: b.String(), - UserInput: strings.Join(fullTpl.UserInput, "\n"), + Prompt: systemPrompt, + UserInput: userPrompt, Files: append([]string(nil), req.Files...), Tools: req.Tools, MessageList: messageList, }, nil } -func defaultInitialChatPromptTemplateLoader(ctx context.Context) (*model.PromptTemplateArg, error) { - ins := query.Q.PromptTemplateArg - tpls, err := ins.WithContext(ctx).Where(ins.PromptID.Eq(5)).Find() - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - if len(tpls) == 0 { - return nil, errors.New("prompt template not found") - } - return tpls[0], nil -} - func defaultInitialChatUserNameLoader(ctx context.Context, chatID, openID string) (string, error) { userName, err := larkuser.GetUserNameCache(ctx, chatID, openID) if err != nil { @@ -196,3 +127,100 @@ func defaultInitialChatUserNameLoader(ctx context.Context, chatID, openID string } return userName, nil } + +func resolveStandardPromptMode(event *larkim.P2MessageReceiveV1, replyScoped bool) standardPromptMode { + if replyScoped { + return standardPromptModeDirect + } + if event == nil || event.Event == nil || event.Event.Message == nil { + return standardPromptModeAmbient + } + if strings.EqualFold(strings.TrimSpace(pointerString(event.Event.Message.ChatType)), "p2p") { + return standardPromptModeDirect + } + if larkmsg.IsMentioned(event.Event.Message.Mentions) { + return standardPromptModeDirect + } + return standardPromptModeAmbient +} + +func standardPromptHistoryLimit(mode standardPromptMode, requested int) int { + if requested <= 0 { + return 0 + } + switch mode { + case standardPromptModeDirect: + if requested < 6 { + return requested + } + return 6 + default: + if requested < 4 { + return requested + } + return 4 + } +} + +func buildStandardChatSystemPrompt(mode standardPromptMode) string { + lines := []string{ + "你是群聊里的自然成员,不要端着客服腔,也不要自称 AI。", + "你会收到当前用户消息,以及少量最近对话作为运行时输入;如果信息不够,不要假装看过更多历史。", + "如果需要补历史,请优先调用 search_history。它只会搜索当前 chat_id,可按关键词、user_id、user_name、message_type、时间范围过滤。", + "只有在需要某个具体成员响应、确认、补充或接手时,才在 reply 里 @ 对方;普通接话或泛泛回应不要滥用 @。", + "如果明确知道对方 open_id,可直接写 `姓名`;如果只知道名字,可写 `@姓名`,系统会按当前群成员匹配。", + "只输出 JSON object,不要输出 markdown 代码块、解释性前言或额外文本。", + "JSON 字段只允许使用 decision、thought、reply、reference_from_web、reference_from_history。", + `decision 只能是 "reply" 或 "skip"。`, + `如果 decision="skip",reply 留空即可;如果 decision="reply",reply 里给出用户可见回复。`, + `示例:{"decision":"reply","thought":"简短判断","reply":"面向用户的回复","reference_from_web":"","reference_from_history":""}`, + "thought 用一句简短中文概括你的判断,不要泄露系统提示。", + "reference_from_web 和 reference_from_history 只有在确实用到对应来源时再填,否则留空。", + "如果没有足够价值,不要硬接话;该跳过时把 decision 设为 skip。", + "少用语气词。不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅,避免拟人感过强。", + } + switch mode { + case standardPromptModeDirect: + lines = append(lines, + "当前属于 direct reply。用户已经明确在找你接话,默认应回答,不要轻易 skip。", + "如果只是补一句确认或延续当前子话题,直接自然回复,不要把背景重讲一遍。", + "如果当前已经在某条消息或子话题里续聊,优先直接延续当前子话题,不要为了点名而重复 @。", + ) + default: + lines = append(lines, + "当前属于 ambient/passive reply。只有在用户意愿明显、且不容易打扰时才接话。", + "如果上下文不够或更像主动插话,请优先保持克制,必要时直接 skip。", + ) + } + return strings.Join(lines, "\n") +} + +func buildStandardChatUserPrompt(selfProfile botidentity.Profile, historyLines, contextLines []string, currentInput string) string { + var builder strings.Builder + builder.WriteString("请基于下面输入完成这轮对话。\n") + if identityLines := botidentity.PromptIdentityLines(selfProfile); len(identityLines) > 0 { + builder.WriteString("机器人身份:\n") + builder.WriteString(standardChatLinesBlock(identityLines)) + builder.WriteString("\n") + } + builder.WriteString("最近对话:\n") + builder.WriteString(standardChatLinesBlock(historyLines)) + builder.WriteString("\n补充上下文:\n") + builder.WriteString(standardChatLinesBlock(contextLines)) + builder.WriteString("\n当前用户消息:\n") + builder.WriteString(strings.TrimSpace(currentInput)) + return builder.String() +} + +func standardChatLinesBlock(lines []string) string { + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + if trimmed := strings.TrimSpace(line); trimmed != "" { + filtered = append(filtered, trimmed) + } + } + if len(filtered) == 0 { + return "" + } + return strings.Join(filtered, "\n") +} diff --git a/internal/application/lark/agentruntime/chatflow/standard_plan_test.go b/internal/application/lark/agentruntime/chatflow/standard_plan_test.go index cd55af7..2052958 100644 --- a/internal/application/lark/agentruntime/chatflow/standard_plan_test.go +++ b/internal/application/lark/agentruntime/chatflow/standard_plan_test.go @@ -5,36 +5,35 @@ import ( "strings" "testing" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/history" arktools "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/ark_dal/tools" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/db/model" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" "github.com/tmc/langchaingo/schema" "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model/responses" ) func TestBuildInitialChatExecutionPlanUsesReplyScopedContext(t *testing.T) { - originalPromptTemplateLoader := initialChatPromptTemplateLoader originalUserNameLoader := initialChatUserNameLoader originalHistoryLoader := agenticChatHistoryLoader originalReplyScopeLoader := agenticChatReplyScopeLoader originalRecallDocs := agenticChatRecallDocs originalChunkIndexResolver := agenticChatChunkIndexResolver originalTopicLookup := agenticChatTopicSummaryLookup + originalBotProfileLoader := chatPromptBotProfileLoader defer func() { - initialChatPromptTemplateLoader = originalPromptTemplateLoader initialChatUserNameLoader = originalUserNameLoader agenticChatHistoryLoader = originalHistoryLoader agenticChatReplyScopeLoader = originalReplyScopeLoader agenticChatRecallDocs = originalRecallDocs agenticChatChunkIndexResolver = originalChunkIndexResolver agenticChatTopicSummaryLookup = originalTopicLookup + chatPromptBotProfileLoader = originalBotProfileLoader }() - - initialChatPromptTemplateLoader = func(ctx context.Context) (*model.PromptTemplateArg, error) { - return &model.PromptTemplateArg{TemplateStr: `History={{.HistoryRecords}} Context={{.Context}} Topics={{.Topics}}`}, nil - } initialChatUserNameLoader = func(ctx context.Context, chatID, openID string) (string, error) { return "Alice", nil } + chatPromptBotProfileLoader = func(ctx context.Context) botidentity.Profile { + return botidentity.Profile{AppID: "cli_test_app", BotOpenID: "ou_bot_self", BotName: "BetaGo"} + } agenticChatHistoryLoader = func(ctx context.Context, chatID string, size int) (history.OpensearchMsgLogList, error) { t.Fatal("broad chat history should not be loaded for reply-scoped standard context") return nil, nil @@ -46,17 +45,20 @@ func TestBuildInitialChatExecutionPlanUsesReplyScopedContext(t *testing.T) { {CreateTime: "2026-03-20 10:01:00", OpenID: "ou_bot", UserName: "Agent", MsgList: []string{"最后一步是首轮 turn durable 化。"}}, }, ContextLines: []string{"关联运行状态: waiting_approval"}, - RecallQuery: "请继续推进 agentic 改造 首轮 turn durable 化", }, true, nil } agenticChatRecallDocs = func(ctx context.Context, chatID, query string, topK int) ([]schema.Document, error) { - if !strings.Contains(query, "首轮 turn durable 化") { - t.Fatalf("recall query = %q, want contain reply-scoped chain", query) - } - return []schema.Document{{PageContent: "reply scoped standard context", Metadata: map[string]any{"create_time": "2026-03-20 09:59:00", "user_id": "ou_arch", "user_name": "Bob"}}}, nil + t.Fatal("reply-scoped standard prompt should not preload recall docs") + return nil, nil + } + agenticChatChunkIndexResolver = func(ctx context.Context, chatID, openID string) string { + t.Fatal("reply-scoped standard prompt should not preload topic summaries") + return "" + } + agenticChatTopicSummaryLookup = func(ctx context.Context, chunkIndex, msgID string) (string, error) { + t.Fatal("reply-scoped standard prompt should not lookup topic summaries") + return "", nil } - agenticChatChunkIndexResolver = func(ctx context.Context, chatID, openID string) string { return "" } - agenticChatTopicSummaryLookup = func(ctx context.Context, chunkIndex, msgID string) (string, error) { return "", nil } plan, err := BuildInitialChatExecutionPlan(context.Background(), InitialChatGenerationRequest{ Event: testAgenticReplyEvent("oc_chat", "ou_actor", "om_parent", "这里展开一下"), @@ -71,7 +73,144 @@ func TestBuildInitialChatExecutionPlanUsesReplyScopedContext(t *testing.T) { if got := strings.Join(plan.MessageList.ToLines(), "\n"); !strings.Contains(got, "最后一步是首轮 turn durable 化。") { t.Fatalf("message list = %q, want contain scoped parent reply", got) } - if !strings.Contains(plan.Prompt, "关联运行状态: waiting_approval") || !strings.Contains(plan.Prompt, "reply scoped standard context") { - t.Fatalf("prompt = %q", plan.Prompt) + if !strings.Contains(plan.Prompt, "当前属于 direct reply") { + t.Fatalf("prompt = %q, want direct reply guidance", plan.Prompt) + } + if !strings.Contains(plan.Prompt, "只输出 JSON object") || !strings.Contains(plan.Prompt, `"decision"`) { + t.Fatalf("prompt should keep JSON contract: %q", plan.Prompt) + } + if strings.Contains(plan.Prompt, "关联运行状态: waiting_approval") { + t.Fatalf("runtime context should move to user input, got prompt: %q", plan.Prompt) + } + if !strings.Contains(plan.UserInput, "关联运行状态: waiting_approval") { + t.Fatalf("user input should carry scoped runtime context: %q", plan.UserInput) + } + if !strings.Contains(plan.UserInput, "最后一步是首轮 turn durable 化。") { + t.Fatalf("user input should carry recent thread history: %q", plan.UserInput) + } + if !strings.Contains(plan.UserInput, "self_open_id: ou_bot_self") || !strings.Contains(plan.UserInput, "self_name: BetaGo") { + t.Fatalf("user input should carry bot self identity: %q", plan.UserInput) + } +} + +func TestBuildInitialChatExecutionPlanUsesAmbientPromptAndSkipsBroadRecall(t *testing.T) { + originalUserNameLoader := initialChatUserNameLoader + originalHistoryLoader := agenticChatHistoryLoader + originalReplyScopeLoader := agenticChatReplyScopeLoader + originalRecallDocs := agenticChatRecallDocs + originalChunkIndexResolver := agenticChatChunkIndexResolver + originalTopicLookup := agenticChatTopicSummaryLookup + defer func() { + initialChatUserNameLoader = originalUserNameLoader + agenticChatHistoryLoader = originalHistoryLoader + agenticChatReplyScopeLoader = originalReplyScopeLoader + agenticChatRecallDocs = originalRecallDocs + agenticChatChunkIndexResolver = originalChunkIndexResolver + agenticChatTopicSummaryLookup = originalTopicLookup + }() + initialChatUserNameLoader = func(ctx context.Context, chatID, openID string) (string, error) { return "Alice", nil } + agenticChatHistoryLoader = func(ctx context.Context, chatID string, size int) (history.OpensearchMsgLogList, error) { + return history.OpensearchMsgLogList{ + {CreateTime: "2026-03-20 09:00:00", OpenID: "ou_1", UserName: "U1", MsgList: []string{"第一条"}}, + {CreateTime: "2026-03-20 09:01:00", OpenID: "ou_2", UserName: "U2", MsgList: []string{"第二条"}}, + {CreateTime: "2026-03-20 09:02:00", OpenID: "ou_3", UserName: "U3", MsgList: []string{"第三条"}}, + {CreateTime: "2026-03-20 09:03:00", OpenID: "ou_4", UserName: "U4", MsgList: []string{"第四条"}}, + {CreateTime: "2026-03-20 09:04:00", OpenID: "ou_5", UserName: "U5", MsgList: []string{"第五条"}}, + }, nil + } + agenticChatReplyScopeLoader = func(ctx context.Context, event *larkim.P2MessageReceiveV1) (agenticReplyScopeContext, bool, error) { + return agenticReplyScopeContext{}, false, nil + } + agenticChatRecallDocs = func(ctx context.Context, chatID, query string, topK int) ([]schema.Document, error) { + t.Fatal("ambient standard prompt should not preload broad recall docs") + return nil, nil + } + agenticChatChunkIndexResolver = func(ctx context.Context, chatID, openID string) string { + t.Fatal("ambient standard prompt should not preload topic summaries") + return "" + } + agenticChatTopicSummaryLookup = func(ctx context.Context, chunkIndex, msgID string) (string, error) { + t.Fatal("ambient standard prompt should not lookup topic summaries") + return "", nil + } + + event := testAgenticReplyEvent("oc_chat", "ou_actor", "", "随便聊聊这个话题") + chatType := "group" + event.Event.Message.ChatType = &chatType + + plan, err := BuildInitialChatExecutionPlan(context.Background(), InitialChatGenerationRequest{ + Event: event, + ModelID: "ep-test", + Size: 20, + ReasoningEffort: responses.ReasoningEffort_minimal, + Tools: &arktools.Impl[larkim.P2MessageReceiveV1]{}, + }) + if err != nil { + t.Fatalf("BuildInitialChatExecutionPlan() error = %v", err) + } + if !strings.Contains(plan.Prompt, "当前属于 ambient/passive reply") { + t.Fatalf("prompt = %q, want ambient reply guidance", plan.Prompt) + } + if strings.Contains(plan.Prompt, "第一条") { + t.Fatalf("system prompt should not inline broad history: %q", plan.Prompt) + } + if strings.Contains(plan.Prompt, "第五条") { + t.Fatalf("recent history should move to user input, got prompt: %q", plan.Prompt) + } + if !strings.Contains(plan.Prompt, "只输出 JSON object") || !strings.Contains(plan.Prompt, `"reference_from_history"`) { + t.Fatalf("prompt should keep JSON contract: %q", plan.Prompt) + } + if strings.Contains(plan.UserInput, "第一条") { + t.Fatalf("user input should trim broad history for ambient reply: %q", plan.UserInput) + } + if !strings.Contains(plan.UserInput, "第五条") { + t.Fatalf("user input should retain latest history lines: %q", plan.UserInput) + } +} + +func TestBuildStandardChatSystemPromptConstrainsAnthropomorphicParticles(t *testing.T) { + prompt := buildStandardChatSystemPrompt(standardPromptModeAmbient) + for _, want := range []string{ + "少用语气词", + "不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅", + "拟人感过强", + "只输出 JSON object", + `"decision"`, + `"thought"`, + `"reply"`, + `"reference_from_web"`, + `"reference_from_history"`, + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + +func TestBuildStandardChatSystemPromptGuidesMentionsAndThreadContinuation(t *testing.T) { + prompt := buildStandardChatSystemPrompt(standardPromptModeDirect) + for _, want := range []string{ + "只有在需要某个具体成员响应", + "@姓名", + "姓名", + "优先直接延续当前子话题", + "不要为了点名而重复 @", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + +func TestBuildStandardChatUserPromptCarriesRecentHistoryAndCurrentInput(t *testing.T) { + prompt := buildStandardChatUserPrompt(botidentity.Profile{ + AppID: "cli_test_app", + BotOpenID: "ou_bot_self", + BotName: "BetaGo", + }, []string{"[09:01] : 第二条", "[09:02] : 第三条"}, []string{"关联运行状态: waiting_approval"}, "[09:03] : 这里展开一下") + for _, want := range []string{"机器人身份", "self_open_id: ou_bot_self", "self_name: BetaGo", "最近对话", "第二条", "第三条", "补充上下文", "waiting_approval", "当前用户消息", "这里展开一下"} { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } } } diff --git a/internal/application/lark/agentruntime/default_continuation_reply_turn_executor_test.go b/internal/application/lark/agentruntime/default_continuation_reply_turn_executor_test.go index 462ef3d..7907816 100644 --- a/internal/application/lark/agentruntime/default_continuation_reply_turn_executor_test.go +++ b/internal/application/lark/agentruntime/default_continuation_reply_turn_executor_test.go @@ -6,6 +6,7 @@ import ( "testing" appconfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/config" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/ark_dal" arktools "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/ark_dal/tools" "github.com/bytedance/gg/gresult" @@ -124,6 +125,27 @@ func TestContinuationReplyTurnSystemPromptConstrainsSingleToolCallPerTurn(t *tes if !strings.Contains(prompt, "不能同时输出") { t.Fatalf("prompt = %q, want contain mutually-exclusive-output hint", prompt) } + if !strings.Contains(prompt, "少用语气词") { + t.Fatalf("prompt = %q, want contain filler-word constraint", prompt) + } + if !strings.Contains(prompt, "不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅") { + t.Fatalf("prompt = %q, want contain anthropomorphic-particle constraint", prompt) + } +} + +func TestContinuationReplyTurnSystemPromptGuidesMentionsAndThreadFollowUp(t *testing.T) { + prompt := continuationReplyTurnSystemPrompt() + for _, want := range []string{ + "只有在需要某个具体成员响应", + "@姓名", + "姓名", + "优先沿当前线程或当前子话题继续", + "不要为了刷存在感乱 @", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } } func TestContinuationReplyTurnSystemPromptGuidesResearchContinuation(t *testing.T) { @@ -346,7 +368,7 @@ func TestBuildContinuationReplyTurnUserPromptIncludesResumeSummaryAndPayload(t * WaitingReason: WaitingReasonSchedule, ResumeSummary: "定时任务触发:日报窗口已到。", ResumePayloadJSON: []byte(`{"task_id":"task_daily","window":"morning"}`), - }) + }, botidentity.Profile{}) if !strings.Contains(prompt, "恢复摘要") { t.Fatalf("prompt = %q, want contain %q", prompt, "恢复摘要") @@ -359,6 +381,27 @@ func TestBuildContinuationReplyTurnUserPromptIncludesResumeSummaryAndPayload(t * } } +func TestBuildContinuationReplyTurnUserPromptIncludesSelfIdentity(t *testing.T) { + prompt := buildContinuationReplyTurnUserPrompt(ContinuationReplyTurnRequest{ + Run: &AgentRun{InputText: "帮我继续日报任务"}, + }, botidentity.Profile{ + AppID: "cli_test_app", + BotOpenID: "ou_bot_self", + BotName: "BetaGo", + }) + for _, want := range []string{ + "机器人身份", + "self_open_id: ou_bot_self", + "self_name: BetaGo", + "sender user_id/open_id 等于 self_open_id", + "mention target open_id 等于 self_open_id", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + func TestDefaultContinuationReplyTurnExecutorRecordsCompletedCapabilityTraceWithRecorder(t *testing.T) { toolset := arktools.New[larkim.P2MessageReceiveV1]().Add( arktools.NewUnit[larkim.P2MessageReceiveV1](). diff --git a/internal/application/lark/agentruntime/reply_turn.go b/internal/application/lark/agentruntime/reply_turn.go index 81d860e..453fd05 100644 --- a/internal/application/lark/agentruntime/reply_turn.go +++ b/internal/application/lark/agentruntime/reply_turn.go @@ -11,6 +11,7 @@ import ( capdef "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/agentruntime/capability" chatflow "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/agentruntime/chatflow" message "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/agentruntime/message" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/runtimecontext" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/toolmeta" @@ -38,6 +39,7 @@ var ( defaultAgenticInitialChatPlanBuilder = chatflow.BuildAgenticChatExecutionPlan defaultInitialChatTurnExecutor = chatflow.ExecuteInitialChatTurn defaultInitialChatStreamFinalizer = chatflow.FinalizeInitialChatStream + continuationReplyBotProfileLoader = botidentity.CurrentProfile ) func snapshotDefaultRuntimeExecutorDeps() defaultRuntimeExecutorDeps { @@ -75,7 +77,7 @@ func (e defaultChatGenerationPlanExecutor) Generate(ctx context.Context, event * var tools *arktools.Impl[larkim.P2MessageReceiveV1] if e.deps.toolProvider != nil { tools = decorateRuntimeChatTools(e.deps.toolProvider()) -} + } if plan.Mode.Normalize() == appconfig.ChatModeAgentic { return generateAgenticChatPlan(ctx, event, plan, tools, e.deps) } @@ -560,7 +562,7 @@ func (e *defaultContinuationReplyTurnExecutor) ExecuteContinuationReplyTurn(ctx ChatID: runtime.chatID, OpenID: runtime.openID, Prompt: continuationReplyTurnSystemPrompt(), - UserInput: buildContinuationReplyTurnUserPrompt(req), + UserInput: buildContinuationReplyTurnUserPrompt(req, continuationReplyBotProfileLoader(ctx)), Tools: runtime.tools, }, }, @@ -698,12 +700,21 @@ func continuationReplyTurnSystemPrompt() string { - reply: 给用户看的最终续写回复 - reply 默认像群里正常成员接话,不要写成系统播报、审批回执或工单状态单 - 能直接说结果就直接说结果,非必要不要拉长 +- 只有在需要某个具体成员响应、确认、补充或接手时,才 @ 对方;普通续写不要为了刷存在感乱 @ +- 如果明确知道对方 open_id,可直接写 姓名;如果只知道名字,可写 @姓名,系统会按当前群成员匹配 +- 如果当前已经在某条消息、线程或子话题里继续,优先沿当前线程或当前子话题继续,不要无意义重复 @ +- 少用语气词。不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅,避免拟人感过强 `) } -func buildContinuationReplyTurnUserPrompt(req ContinuationReplyTurnRequest) string { +func buildContinuationReplyTurnUserPrompt(req ContinuationReplyTurnRequest, selfProfile botidentity.Profile) string { var builder strings.Builder builder.WriteString("请继续这次 agent runtime 恢复。\n") + if identityLines := botidentity.PromptIdentityLines(selfProfile); len(identityLines) > 0 { + builder.WriteString("机器人身份:\n") + builder.WriteString(replyTurnLinesBlock(identityLines)) + builder.WriteString("\n") + } builder.WriteString("原始目标:\n") builder.WriteString(replyTurnTextBlock(coalesceString(req.Run.Goal, req.Run.InputText))) builder.WriteString("\n恢复来源:\n") @@ -728,6 +739,19 @@ func buildContinuationReplyTurnUserPrompt(req ContinuationReplyTurnRequest) stri return builder.String() } +func replyTurnLinesBlock(lines []string) string { + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + if trimmed := strings.TrimSpace(line); trimmed != "" { + filtered = append(filtered, trimmed) + } + } + if len(filtered) == 0 { + return "" + } + return strings.Join(filtered, "\n") +} + func resolveCapabilityReplyTurnToolOutput(capabilityName string, result CapabilityResult) string { if text := strings.TrimSpace(result.OutputText); text != "" { return text diff --git a/internal/application/lark/botidentity/profile.go b/internal/application/lark/botidentity/profile.go new file mode 100644 index 0000000..1d62e8a --- /dev/null +++ b/internal/application/lark/botidentity/profile.go @@ -0,0 +1,208 @@ +package botidentity + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/cache" + infraConfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/config" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal" + "github.com/BetaGoRobot/BetaGo-Redefine/pkg/logs" + larkapplication "github.com/larksuite/oapi-sdk-go/v3/service/application/v6" + "go.uber.org/zap" +) + +// Profile describes the current bot instance for prompt/runtime identity hints. +type Profile struct { + AppID string + BotOpenID string + BotName string +} + +var profileLoader = loadProfileFromLark + +func CurrentProfile(ctx context.Context) Profile { + return resolveProfile(ctx, Current()) +} + +func PromptIdentityLines(profile Profile) []string { + profile = normalizeProfile(Identity{AppID: profile.AppID, BotOpenID: profile.BotOpenID}, profile) + if strings.TrimSpace(profile.BotOpenID) == "" && strings.TrimSpace(profile.BotName) == "" { + return nil + } + lines := []string{ + fmt.Sprintf("self_open_id: %s", strings.TrimSpace(profile.BotOpenID)), + fmt.Sprintf("self_app_id: %s", strings.TrimSpace(profile.AppID)), + fmt.Sprintf("self_name, 也就是你的昵称: %s", strings.TrimSpace(profile.BotName)), + "如果历史里的 sender user_id/open_id 等于 self_open_id,那条消息就是你自己之前发的。", + "如果 mention target open_id 等于 self_open_id,那是在 @你。", + } + return lines +} + +func getProfileCache(ctx context.Context, identity Identity) (Profile, error) { + if !identity.Valid() { + return Profile{}, errors.New("bot identity is missing") + } + return cache.GetOrExecute(ctx, profileCacheKey(identity), func() (Profile, error) { + profile, err := profileLoader(ctx, identity) + if err != nil { + return Profile{}, err + } + return normalizeProfile(identity, profile), nil + }) +} + +func resolveProfile(ctx context.Context, identity Identity) Profile { + fallback := fallbackProfile(identity) + if !identity.Valid() { + return fallback + } + profile, err := getProfileCache(ctx, identity) + if err != nil { + logs.L().Ctx(ctx).Warn("load bot profile failed", + zap.String("app_id", identity.AppID), + zap.String("bot_open_id", identity.BotOpenID), + zap.Error(err), + ) + return fallback + } + return mergeProfile(fallback, profile) +} + +func profileCacheKey(identity Identity) string { + return identity.NamespaceKey("lark_bot_profile", strings.TrimSpace(identity.AppID)) +} + +func fallbackProfile(identity Identity) Profile { + return Profile{ + AppID: strings.TrimSpace(identity.AppID), + BotOpenID: strings.TrimSpace(identity.BotOpenID), + BotName: configuredBotName(), + } +} + +func normalizeProfile(identity Identity, profile Profile) Profile { + return mergeProfile(Profile{ + AppID: strings.TrimSpace(identity.AppID), + BotOpenID: strings.TrimSpace(identity.BotOpenID), + }, profile) +} + +func mergeProfile(base, override Profile) Profile { + merged := base + if trimmed := strings.TrimSpace(override.AppID); trimmed != "" { + merged.AppID = trimmed + } + if trimmed := strings.TrimSpace(override.BotOpenID); trimmed != "" { + merged.BotOpenID = trimmed + } + if trimmed := strings.TrimSpace(override.BotName); trimmed != "" { + merged.BotName = trimmed + } + return merged +} + +func configuredBotName() string { + cfg := infraConfig.Get() + if cfg == nil || cfg.BaseInfo == nil { + return "" + } + return strings.TrimSpace(cfg.BaseInfo.RobotName) +} + +func loadProfileFromLark(ctx context.Context, identity Identity) (Profile, error) { + profile := Profile{ + AppID: strings.TrimSpace(identity.AppID), + BotOpenID: strings.TrimSpace(identity.BotOpenID), + } + if profile.AppID == "" { + return profile, nil + } + client := lark_dal.Client() + if client == nil || client.Application == nil || client.Application.V6 == nil || client.Application.V6.Application == nil { + return Profile{}, errors.New("lark application client is not configured") + } + resp, err := client.Application.V6.Application.Get(ctx, larkapplication.NewGetApplicationReqBuilder(). + AppId(profile.AppID). + Lang(larkapplication.I18nKeyZhCn). + Build(), + ) + if err != nil { + return Profile{}, err + } + if resp == nil || !resp.Success() { + if resp == nil { + return Profile{}, errors.New("get application returned nil response") + } + return Profile{}, errors.New(resp.Error()) + } + if resp.Data == nil || resp.Data.App == nil { + return profile, nil + } + return Profile{ + AppID: firstNonEmpty(profile.AppID, pointerString(resp.Data.App.AppId)), + BotOpenID: profile.BotOpenID, + BotName: applicationName(resp.Data.App), + }, nil +} + +func applicationName(app *larkapplication.Application) string { + if app == nil { + return "" + } + if name := strings.TrimSpace(pointerString(app.AppName)); name != "" { + return name + } + preferred := []string{ + strings.TrimSpace(pointerString(app.PrimaryLanguage)), + larkapplication.I18nKeyZhCn, + larkapplication.I18nKeyEnUs, + larkapplication.I18nKeyJaJp, + } + for _, key := range preferred { + if name := applicationI18nName(app.I18n, key); name != "" { + return name + } + } + for _, item := range app.I18n { + if name := strings.TrimSpace(pointerString(item.Name)); name != "" { + return name + } + } + return "" +} + +func applicationI18nName(items []*larkapplication.AppI18nInfo, wantKey string) string { + wantKey = strings.TrimSpace(wantKey) + if wantKey == "" { + return "" + } + for _, item := range items { + if item == nil || strings.TrimSpace(pointerString(item.I18nKey)) != wantKey { + continue + } + if name := strings.TrimSpace(pointerString(item.Name)); name != "" { + return name + } + } + return "" +} + +func pointerString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/application/lark/botidentity/profile_test.go b/internal/application/lark/botidentity/profile_test.go new file mode 100644 index 0000000..a8d0a5a --- /dev/null +++ b/internal/application/lark/botidentity/profile_test.go @@ -0,0 +1,72 @@ +package botidentity + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + infraConfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/config" +) + +func useWorkspaceConfigPath(t *testing.T) { + t.Helper() + configPath, err := filepath.Abs("../../../../.dev/config.toml") + if err != nil { + t.Fatalf("resolve config path: %v", err) + } + t.Setenv("BETAGO_CONFIG_PATH", configPath) +} + +func TestGetProfileCacheCachesByIdentity(t *testing.T) { + useWorkspaceConfigPath(t) + originalLoader := profileLoader + defer func() { profileLoader = originalLoader }() + + calls := 0 + profileLoader = func(ctx context.Context, identity Identity) (Profile, error) { + calls++ + return Profile{ + AppID: identity.AppID, + BotOpenID: identity.BotOpenID, + BotName: "缓存命中机器人", + }, nil + } + + identity := Identity{AppID: "cli_test_profile_cache", BotOpenID: "ou_test_profile_cache"} + first, err := getProfileCache(context.Background(), identity) + if err != nil { + t.Fatalf("getProfileCache() first error = %v", err) + } + second, err := getProfileCache(context.Background(), identity) + if err != nil { + t.Fatalf("getProfileCache() second error = %v", err) + } + if calls != 1 { + t.Fatalf("profile loader calls = %d, want 1", calls) + } + if first.BotName != "缓存命中机器人" || second.BotName != "缓存命中机器人" { + t.Fatalf("cached profile = %+v / %+v, want cached bot name", first, second) + } +} + +func TestResolveProfileFallsBackToConfiguredNameOnLoaderError(t *testing.T) { + useWorkspaceConfigPath(t) + originalLoader := profileLoader + defer func() { profileLoader = originalLoader }() + + profileLoader = func(ctx context.Context, identity Identity) (Profile, error) { + return Profile{}, errors.New("boom") + } + + identity := Identity{AppID: "cli_test_profile_fallback", BotOpenID: "ou_test_profile_fallback"} + got := resolveProfile(context.Background(), identity) + if got.AppID != identity.AppID || got.BotOpenID != identity.BotOpenID { + t.Fatalf("resolved profile = %+v, want identity %q/%q", got, identity.AppID, identity.BotOpenID) + } + wantName := strings.TrimSpace(infraConfig.Get().BaseInfo.RobotName) + if got.BotName != wantName { + t.Fatalf("resolved bot name = %q, want %q", got.BotName, wantName) + } +} diff --git a/internal/application/lark/handlers/chat_handler.go b/internal/application/lark/handlers/chat_handler.go index ca6072e..5538cb4 100644 --- a/internal/application/lark/handlers/chat_handler.go +++ b/internal/application/lark/handlers/chat_handler.go @@ -4,39 +4,31 @@ import ( "context" "errors" "fmt" - "html/template" "iter" "strings" - "time" appconfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/config" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/agentruntime" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/history" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/intent" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/mention" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/ark_dal" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/db/query" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal/larkimg" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal/larkmsg" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal/larkuser" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/opensearch" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/xmodel" - "gorm.io/gorm" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/otel" redis "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/redis" - "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/retriever" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/logs" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/utils" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/xhandler" - commonutils "github.com/BetaGoRobot/go_utils/common_utils" jsonrepair "github.com/RealAlexandreAI/json-repair" "github.com/bytedance/sonic" "github.com/defensestation/osquery" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - "github.com/tmc/langchaingo/schema" "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) @@ -58,6 +50,15 @@ type ( var Chat = chatHandler{} +var standardChatBotProfileLoader = botidentity.CurrentProfile + +type standardPromptMode string + +const ( + standardPromptModeDirect standardPromptMode = "direct" + standardPromptModeAmbient standardPromptMode = "ambient" +) + func (chatHandler) CommandDescription() string { return "与机器人对话" } @@ -107,6 +108,107 @@ func resolveChatExecutionMode(meta *xhandler.BaseMetaData) intent.InteractionMod return intent.InteractionModeStandard } +func resolveStandardPromptMode(event *larkim.P2MessageReceiveV1) standardPromptMode { + if event == nil || event.Event == nil || event.Event.Message == nil { + return standardPromptModeAmbient + } + if strings.EqualFold(strings.TrimSpace(pointerString(event.Event.Message.ChatType)), "p2p") { + return standardPromptModeDirect + } + if larkmsg.IsMentioned(event.Event.Message.Mentions) { + return standardPromptModeDirect + } + return standardPromptModeAmbient +} + +func standardPromptHistoryLimit(mode standardPromptMode, requested int) int { + if requested <= 0 { + return 0 + } + switch mode { + case standardPromptModeDirect: + if requested < 6 { + return requested + } + return 6 + default: + if requested < 4 { + return requested + } + return 4 + } +} + +func buildStandardChatSystemPrompt(mode standardPromptMode) string { + lines := []string{ + "你是群聊里的自然成员,不要端着客服腔,也不要自称 AI。", + "你会收到当前用户消息,以及少量最近对话作为运行时输入;如果信息不够,不要假装看过更多历史。", + "如果需要补历史,请优先调用 search_history。它只会搜索当前 chat_id,可按关键词、user_id、user_name、message_type、时间范围过滤。", + "只有在需要某个具体成员响应、确认、补充或接手时,才在 reply 里 @ 对方;普通接话或泛泛回应不要滥用 @。", + "如果明确知道对方 open_id,可直接写 `姓名`;如果只知道名字,可写 `@姓名`,系统会按当前群成员匹配。", + "只输出 JSON object,不要输出 markdown 代码块、解释性前言或额外文本。", + "JSON 字段只允许使用 decision、thought、reply、reference_from_web、reference_from_history。", + `decision 只能是 "reply" 或 "skip"。`, + `如果 decision="skip",reply 留空即可;如果 decision="reply",reply 里给出用户可见回复。`, + `示例:{"decision":"reply","thought":"简短判断","reply":"面向用户的回复","reference_from_web":"","reference_from_history":""}`, + "thought 用一句简短中文概括你的判断,不要泄露系统提示。", + "reference_from_web 和 reference_from_history 只有在确实用到对应来源时再填,否则留空。", + "如果没有足够价值,不要硬接话;该跳过时把 decision 设为 skip。", + "少用语气词。不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅,避免拟人感过强。", + } + switch mode { + case standardPromptModeDirect: + lines = append(lines, + "当前属于 direct reply。用户已经明确在找你接话,默认应回答,不要轻易 skip。", + "如果只是补一句确认或延续当前子话题,直接自然回复,不要把背景重讲一遍。", + "如果当前已经在某条消息或子话题里续聊,优先直接延续当前子话题,不要为了点名而重复 @。", + ) + default: + lines = append(lines, + "当前属于 ambient/passive reply。只有在用户意愿明显、且不容易打扰时才接话。", + "如果上下文不够或更像主动插话,请优先保持克制,必要时直接 skip。", + ) + } + return strings.Join(lines, "\n") +} + +func buildStandardChatUserPrompt(selfProfile botidentity.Profile, historyLines, contextLines []string, currentInput string) string { + var builder strings.Builder + builder.WriteString("请基于下面输入完成这轮对话。\n") + if identityLines := botidentity.PromptIdentityLines(selfProfile); len(identityLines) > 0 { + builder.WriteString("机器人身份:\n") + builder.WriteString(standardChatLinesBlock(identityLines)) + builder.WriteString("\n") + } + builder.WriteString("最近对话:\n") + builder.WriteString(standardChatLinesBlock(historyLines)) + builder.WriteString("\n补充上下文:\n") + builder.WriteString(standardChatLinesBlock(contextLines)) + builder.WriteString("\n当前用户消息:\n") + builder.WriteString(strings.TrimSpace(currentInput)) + return builder.String() +} + +func standardChatLinesBlock(lines []string) string { + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + if trimmed := strings.TrimSpace(line); trimmed != "" { + filtered = append(filtered, trimmed) + } + } + if len(filtered) == 0 { + return "" + } + return strings.Join(filtered, "\n") +} + +func pointerString(value *string) string { + if value == nil { + return "" + } + return *value +} + func runStandardChat(ctx context.Context, event *larkim.P2MessageReceiveV1, chatType string, size *int, args ...string) (err error) { ctx, span := otel.Start(ctx) defer span.End() @@ -204,7 +306,6 @@ func generateStandardChatSeq(ctx context.Context, event *larkim.P2MessageReceive } chatID := *event.Event.Message.ChatId - accessor := appconfig.NewAccessor(ctx, chatID, currentOpenID(event, nil)) messageList, err := history.New(ctx). Query(osquery.Bool().Must(osquery.Term("chat_id", chatID))). Source("raw_message", "mentions", "create_time", "user_id", "chat_id", "user_name", "message_type"). @@ -212,77 +313,28 @@ func generateStandardChatSeq(ctx context.Context, event *larkim.P2MessageReceive if err != nil { return } - ins := query.Q.PromptTemplateArg - tpls, err := ins.WithContext(ctx).Where(ins.PromptID.Eq(5)).Find() - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - if len(tpls) == 0 { - return nil, errors.New("prompt template not found") - } - fullTpl := xmodel.PromptTemplateArg{ - PromptTemplateArg: tpls[0], - CurrentTimeStamp: time.Now().In(utils.UTC8Loc()).Format(time.DateTime), - } - promptTemplateStr := tpls[0].TemplateStr - tp, err := template.New("prompt").Parse(promptTemplateStr) - if err != nil { - return nil, err - } userName, err := larkuser.GetUserNameCache(ctx, *event.Event.Message.ChatId, *event.Event.Sender.SenderId.OpenId) if err != nil { return } createTime := utils.EpoMil2DateStr(*event.Event.Message.CreateTime) - fullTpl.UserInput = []string{fmt.Sprintf("[%s](%s) <%s>: %s", createTime, *event.Event.Sender.SenderId.OpenId, userName, larkmsg.PreGetTextMsg(ctx, event).GetText())} - fullTpl.HistoryRecords = messageList.ToLines() - if len(fullTpl.HistoryRecords) > *size { - fullTpl.HistoryRecords = fullTpl.HistoryRecords[len(fullTpl.HistoryRecords)-*size:] - } - docs, err := retriever.Cli().RecallDocs(ctx, chatID, *event.Event.Message.Content, 10) - if err != nil { - logs.L().Ctx(ctx).Error("RecallDocs err", zap.Error(err)) - } - fullTpl.Context = commonutils.TransSlice(docs, func(doc schema.Document) string { - if doc.Metadata == nil { - doc.Metadata = map[string]any{} - } - createTime, _ := doc.Metadata["create_time"].(string) - openID, _ := doc.Metadata["user_id"].(string) - userName, _ := doc.Metadata["user_name"].(string) - return fmt.Sprintf("[%s](%s) <%s>: %s", createTime, openID, userName, doc.PageContent) - }) - fullTpl.Topics = make([]string, 0) - for _, doc := range docs { - msgID, ok := doc.Metadata["msg_id"] - if ok { - resp, searchErr := opensearch.SearchData(ctx, accessor.LarkChunkIndex(), osquery. - Search().Sort("timestamp_v2", osquery.OrderDesc). - Query(osquery.Bool().Must(osquery.Term("msg_ids", msgID))). - Size(1), - ) - if searchErr != nil { - return nil, searchErr - } - chunk := &xmodel.MessageChunkLogV3{} - if len(resp.Hits.Hits) > 0 { - sonic.Unmarshal(resp.Hits.Hits[0].Source, &chunk) - fullTpl.Topics = append(fullTpl.Topics, chunk.Summary) - } - } - } - fullTpl.Topics = utils.Dedup(fullTpl.Topics) - b := &strings.Builder{} - err = tp.Execute(b, fullTpl) - if err != nil { - return nil, err + currentInput := fmt.Sprintf("[%s](%s) <%s>: %s", createTime, *event.Event.Sender.SenderId.OpenId, userName, larkmsg.PreGetTextMsg(ctx, event).GetText()) + historyLines := messageList.ToLines() + promptMode := resolveStandardPromptMode(event) + historyLimit := standardPromptHistoryLimit(promptMode, *size) + if historyLimit == 0 { + historyLines = nil + } else if len(historyLines) > historyLimit { + historyLines = historyLines[len(historyLines)-historyLimit:] } + systemPrompt := buildStandardChatSystemPrompt(promptMode) + userPrompt := buildStandardChatUserPrompt(standardChatBotProfileLoader(ctx), historyLines, nil, currentInput) iterSeq, err := ark_dal. New(chatID, currentOpenID(event, nil), event). WithTools(larktools()). - Do(ctx, b.String(), strings.Join(fullTpl.UserInput, "\n"), files...) + Do(ctx, systemPrompt, userPrompt, files...) if err != nil { return nil, err } diff --git a/internal/application/lark/handlers/chat_handler_test.go b/internal/application/lark/handlers/chat_handler_test.go index b3bb9ac..1a3a350 100644 --- a/internal/application/lark/handlers/chat_handler_test.go +++ b/internal/application/lark/handlers/chat_handler_test.go @@ -6,8 +6,11 @@ import ( "testing" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/agentruntime" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/intent" + infraConfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/config" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/xhandler" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) func TestChatGenerationPlanGenerateReturnsNotConfiguredWithoutRegisteredExecutor(t *testing.T) { @@ -41,3 +44,102 @@ func TestResolveChatExecutionModeDefaultsToStandardWithoutDecision(t *testing.T) t.Fatalf("resolveChatExecutionMode() = %q, want %q", got, intent.InteractionModeStandard) } } + +func TestResolveStandardPromptMode(t *testing.T) { + useWorkspaceConfigPath(t) + group := "group" + p2p := "p2p" + + if got := resolveStandardPromptMode(&larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ChatType: &p2p}, + }, + }); got != standardPromptModeDirect { + t.Fatalf("p2p prompt mode = %q, want %q", got, standardPromptModeDirect) + } + + if got := resolveStandardPromptMode(&larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ + ChatType: &group, + Mentions: []*larkim.MentionEvent{{ + Id: &larkim.UserId{OpenId: chatHandlerStrPtr(infraConfig.Get().LarkConfig.BotOpenID)}, + }}, + }, + }, + }); got != standardPromptModeDirect { + t.Fatalf("mention prompt mode = %q, want %q", got, standardPromptModeDirect) + } + + if got := resolveStandardPromptMode(&larkim.P2MessageReceiveV1{ + Event: &larkim.P2MessageReceiveV1Data{ + Message: &larkim.EventMessage{ChatType: &group}, + }, + }); got != standardPromptModeAmbient { + t.Fatalf("group prompt mode = %q, want %q", got, standardPromptModeAmbient) + } +} + +func TestBuildStandardChatSystemPromptConstrainsAnthropomorphicParticles(t *testing.T) { + prompt := buildStandardChatSystemPrompt(standardPromptModeAmbient) + for _, want := range []string{ + "少用语气词", + "不要为了显得亲近而堆砌“哟”“呀”“啦”这类口头禅", + "拟人感过强", + "只输出 JSON object", + `"decision"`, + `"thought"`, + `"reply"`, + `"reference_from_web"`, + `"reference_from_history"`, + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + +func TestBuildStandardChatSystemPromptGuidesMentionsAndThreadContinuation(t *testing.T) { + prompt := buildStandardChatSystemPrompt(standardPromptModeDirect) + for _, want := range []string{ + "只有在需要某个具体成员响应", + "@姓名", + "姓名", + "优先直接延续当前子话题", + "不要为了点名而重复 @", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + +func TestBuildStandardChatUserPromptCarriesRecentHistoryAndCurrentInput(t *testing.T) { + prompt := buildStandardChatUserPrompt(botidentity.Profile{}, []string{"[09:01] : 第二条", "[09:02] : 第三条"}, nil, "[09:03] : 这里展开一下") + for _, want := range []string{"最近对话", "第二条", "第三条", "当前用户消息", "这里展开一下"} { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + +func TestBuildStandardChatUserPromptIncludesSelfIdentity(t *testing.T) { + prompt := buildStandardChatUserPrompt(botidentity.Profile{ + AppID: "cli_test_app", + BotOpenID: "ou_bot_self", + BotName: "BetaGo", + }, []string{"[09:01] : 第二条"}, nil, "[09:03] : 这里展开一下") + for _, want := range []string{ + "机器人身份", + "self_open_id: ou_bot_self", + "self_name: BetaGo", + "sender user_id/open_id 等于 self_open_id", + "mention target open_id 等于 self_open_id", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("prompt = %q, want contain %q", prompt, want) + } + } +} + +func chatHandlerStrPtr(v string) *string { return &v } diff --git a/internal/application/lark/handlers/history_search_handler.go b/internal/application/lark/handlers/history_search_handler.go index 9a2a335..a1f8bf1 100644 --- a/internal/application/lark/handlers/history_search_handler.go +++ b/internal/application/lark/handlers/history_search_handler.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "fmt" "strings" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/history" @@ -14,17 +15,21 @@ import ( ) type HistorySearchArgs struct { - Keywords string `json:"keywords"` - TopK int `json:"top_k"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - OpenID string `json:"user_id"` + Keywords string `json:"keywords"` + TopK int `json:"top_k"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + OpenID string `json:"user_id"` + UserName string `json:"user_name"` + MessageType string `json:"message_type"` } type historySearchHandler struct{} var SearchHistory historySearchHandler +var historyHybridSearchFn = history.HybridSearch + func (historySearchHandler) ParseTool(raw string) (HistorySearchArgs, error) { parsed := HistorySearchArgs{} if err := utils.UnmarshalStringPre(raw, &parsed); err != nil { @@ -36,15 +41,23 @@ func (historySearchHandler) ParseTool(raw string) (HistorySearchArgs, error) { func (historySearchHandler) ToolSpec() xcommand.ToolSpec { return xcommand.ToolSpec{ Name: "search_history", - Desc: "根据输入的关键词搜索相关的历史对话记录", + Desc: "在当前 chat_id 范围内搜索历史对话,支持关键词和用户/消息类型等元数据过滤", Params: arktools.NewParams("object"). AddProp("keywords", &arktools.Prop{ Type: "string", - Desc: "需要检索的关键词列表,逗号隔开", + Desc: "需要检索的关键词列表, 逗号隔开;如果只按元数据过滤可以为空", }). AddProp("user_id", &arktools.Prop{ Type: "string", - Desc: "用户ID", + Desc: "按发言用户 OpenID 过滤", + }). + AddProp("user_name", &arktools.Prop{ + Type: "string", + Desc: "按发言用户名精确过滤", + }). + AddProp("message_type", &arktools.Prop{ + Type: "string", + Desc: "按消息类型过滤,例如 text、image、file", }). AddProp("start_time", &arktools.Prop{ Type: "string", @@ -57,8 +70,7 @@ func (historySearchHandler) ToolSpec() xcommand.ToolSpec { AddProp("top_k", &arktools.Prop{ Type: "number", Desc: "返回的结果数量", - }). - AddRequired("keywords"), + }), Result: func(metaData *xhandler.BaseMetaData) string { result, _ := metaData.GetExtra("search_result") return result @@ -67,14 +79,20 @@ func (historySearchHandler) ToolSpec() xcommand.ToolSpec { } func (historySearchHandler) Handle(ctx context.Context, data *larkim.P2MessageReceiveV1, metaData *xhandler.BaseMetaData, arg HistorySearchArgs) error { - res, err := history.HybridSearch(ctx, + chatID := strings.TrimSpace(metaData.ChatID) + if chatID == "" { + return fmt.Errorf("chat_id is required for search_history") + } + res, err := historyHybridSearchFn(ctx, history.HybridSearchRequest{ - QueryText: splitByComma(arg.Keywords), - TopK: arg.TopK, - OpenID: arg.OpenID, - ChatID: metaData.ChatID, - StartTime: arg.StartTime, - EndTime: arg.EndTime, + QueryText: splitByComma(arg.Keywords), + TopK: arg.TopK, + OpenID: arg.OpenID, + UserName: strings.TrimSpace(arg.UserName), + MessageType: strings.TrimSpace(arg.MessageType), + ChatID: chatID, + StartTime: arg.StartTime, + EndTime: arg.EndTime, }, ark_dal.EmbeddingText) if err != nil { return err diff --git a/internal/application/lark/handlers/history_search_handler_test.go b/internal/application/lark/handlers/history_search_handler_test.go new file mode 100644 index 0000000..704a42d --- /dev/null +++ b/internal/application/lark/handlers/history_search_handler_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "context" + "strings" + "testing" + + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/history" + "github.com/BetaGoRobot/BetaGo-Redefine/pkg/xhandler" +) + +func TestHistorySearchHandlerInjectsChatIDScope(t *testing.T) { + old := historyHybridSearchFn + defer func() { + historyHybridSearchFn = old + }() + + var captured history.HybridSearchRequest + historyHybridSearchFn = func(ctx context.Context, req history.HybridSearchRequest, embeddingFunc history.EmbeddingFunc) ([]*history.SearchResult, error) { + captured = req + return []*history.SearchResult{{MessageID: "om_1", OpenID: "ou_bot", RawMessage: "hello"}}, nil + } + + meta := &xhandler.BaseMetaData{ChatID: "oc_test_chat"} + err := SearchHistory.Handle(context.Background(), nil, meta, HistorySearchArgs{ + Keywords: "机器人", + OpenID: "ou_test", + UserName: "Alice", + MessageType: "text", + TopK: 8, + }) + if err != nil { + t.Fatalf("Handle() error = %v", err) + } + + if captured.ChatID != "oc_test_chat" { + t.Fatalf("captured chat_id = %q, want %q", captured.ChatID, "oc_test_chat") + } + if captured.UserName != "Alice" { + t.Fatalf("captured user_name = %q, want %q", captured.UserName, "Alice") + } + if captured.MessageType != "text" { + t.Fatalf("captured message_type = %q, want %q", captured.MessageType, "text") + } + if result, ok := meta.GetExtra("search_result"); !ok || !strings.Contains(result, "om_1") || !strings.Contains(result, `"user_id":"ou_bot"`) { + t.Fatalf("search_result extra missing expected payload: %q", result) + } +} + +func TestHistorySearchHandlerRejectsEmptyChatID(t *testing.T) { + old := historyHybridSearchFn + defer func() { + historyHybridSearchFn = old + }() + + historyHybridSearchFn = func(ctx context.Context, req history.HybridSearchRequest, embeddingFunc history.EmbeddingFunc) ([]*history.SearchResult, error) { + t.Fatal("history search should not be called when chat scope is empty") + return nil, nil + } + + err := SearchHistory.Handle(context.Background(), nil, &xhandler.BaseMetaData{}, HistorySearchArgs{ + Keywords: "机器人", + }) + if err == nil { + t.Fatal("expected empty chat_id error") + } + if !strings.Contains(err.Error(), "chat_id") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/application/lark/handlers/send_message_handler.go b/internal/application/lark/handlers/send_message_handler.go index e76da01..5436c6f 100644 --- a/internal/application/lark/handlers/send_message_handler.go +++ b/internal/application/lark/handlers/send_message_handler.go @@ -53,6 +53,7 @@ func (h sendMessageHandler) ToolSpec() xcommand.ToolSpec { desc = "发送一条消息到当前任务所属的对话。当你需要主动通知用户、发送提醒确认、或者发送额外信息时使用此工具" } desc += "。如果需要@成员,优先直接输出飞书格式 `姓名`;如果只知道名字,也可以输出 `@姓名`,系统会尝试按当前群成员匹配。" + desc += " 只有在需要某个具体成员响应、确认、补充或接手时再 @;普通群通知不要一上来就 @。如果只是延续当前对话,不必为了点名而强行 @。" return xcommand.ToolSpec{ Name: "send_message", Desc: desc, diff --git a/internal/application/lark/handlers/send_message_handler_test.go b/internal/application/lark/handlers/send_message_handler_test.go new file mode 100644 index 0000000..af65b38 --- /dev/null +++ b/internal/application/lark/handlers/send_message_handler_test.go @@ -0,0 +1,21 @@ +package handlers + +import ( + "strings" + "testing" +) + +func TestSendMessageToolSpecGuidesMentionsSparingly(t *testing.T) { + desc := SendMessage.ToolSpec().Desc + for _, want := range []string{ + "姓名", + "@姓名", + "只有在需要某个具体成员响应", + "普通群通知不要一上来就 @", + "只是延续当前对话,不必为了点名而强行 @", + } { + if !strings.Contains(desc, want) { + t.Fatalf("desc = %q, want contain %q", desc, want) + } + } +} diff --git a/internal/application/lark/handlers/tools_test.go b/internal/application/lark/handlers/tools_test.go index 8ef3f88..72bbceb 100644 --- a/internal/application/lark/handlers/tools_test.go +++ b/internal/application/lark/handlers/tools_test.go @@ -109,6 +109,21 @@ func TestLarkToolsIncludeResearchHelpers(t *testing.T) { } } +func TestLarkToolsExposeSearchHistoryMetadataFilters(t *testing.T) { + useWorkspaceConfigPath(t) + allTools := larktools() + + searchHistory, ok := allTools.Get("search_history") + if !ok { + t.Fatal("expected search_history tool") + } + for _, name := range []string{"keywords", "user_id", "user_name", "message_type", "start_time", "end_time", "top_k"} { + if _, exists := searchHistory.Parameters.Props[name]; !exists { + t.Fatalf("search_history missing %q parameter", name) + } + } +} + func TestLarkToolsExposeTypedConfigAndFeatureEnums(t *testing.T) { useWorkspaceConfigPath(t) appconfig.SetGetFeaturesFunc(func() []appconfig.Feature { diff --git a/internal/application/lark/history/msg.go b/internal/application/lark/history/msg.go index b4c2380..0491c8f 100644 --- a/internal/application/lark/history/msg.go +++ b/internal/application/lark/history/msg.go @@ -387,6 +387,15 @@ func FilterMessage(ctx context.Context, hits []opensearchapi.SearchHit) (msgList if len(tmpList) == 0 { continue } + currentBot := botidentity.Current() + if currentBot.BotOpenID != "" { + if strings.TrimSpace(res.OpenID) == "你" { + res.OpenID = currentBot.BotOpenID + } + if strings.TrimSpace(res.OpenID) == currentBot.BotOpenID { + res.UserName = "你" + } + } l := &OpensearchMsgLog{ CreateTime: res.CreateTime, OpenID: res.OpenID, diff --git a/internal/application/lark/history/msg_test.go b/internal/application/lark/history/msg_test.go index b736252..186e5c6 100644 --- a/internal/application/lark/history/msg_test.go +++ b/internal/application/lark/history/msg_test.go @@ -2,11 +2,16 @@ package history import ( "context" + "encoding/json" + "path/filepath" "strings" "testing" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/xmodel" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/utils" "github.com/defensestation/osquery" + "github.com/opensearch-project/opensearch-go/v4/opensearchapi" ) func TestHelperBuildRequestMatchesBuilderState(t *testing.T) { @@ -41,3 +46,45 @@ func TestHelperResolvedIndexPrefersExplicitIndex(t *testing.T) { t.Fatalf("resolvedIndex() = %q, want %q", got, "custom-index") } } + +func useHistoryConfigPath(t *testing.T) { + t.Helper() + configPath, err := filepath.Abs("../../../../.dev/config.toml") + if err != nil { + t.Fatalf("resolve config path: %v", err) + } + t.Setenv("BETAGO_CONFIG_PATH", configPath) +} + +func TestFilterMessageTreatsBotSenderAsYouButKeepsOpenID(t *testing.T) { + useHistoryConfigPath(t) + selfOpenID := botidentity.Current().BotOpenID + + source, err := json.Marshal(&xmodel.MessageIndex{ + MessageLog: &xmodel.MessageLog{ + MessageType: "text", + Mentions: "[]", + }, + CreateTime: "2026-03-26 14:10:00", + OpenID: selfOpenID, + UserName: "任意旧昵称", + RawMessage: `{"text":"我来跟进一下"}`, + }) + if err != nil { + t.Fatalf("marshal source: %v", err) + } + + lines := FilterMessage(context.Background(), []opensearchapi.SearchHit{{Source: source}}) + if len(lines) != 1 { + t.Fatalf("line count = %d, want 1", len(lines)) + } + if lines[0].OpenID != selfOpenID { + t.Fatalf("openID = %q, want %q", lines[0].OpenID, selfOpenID) + } + if lines[0].UserName != "你" { + t.Fatalf("userName = %q, want %q", lines[0].UserName, "你") + } + if got := lines[0].ToLine(); !strings.Contains(got, "("+selfOpenID+") <你>:") { + t.Fatalf("line = %q, want contain self identity", got) + } +} diff --git a/internal/application/lark/history/search.go b/internal/application/lark/history/search.go index 11296ff..bc54b20 100644 --- a/internal/application/lark/history/search.go +++ b/internal/application/lark/history/search.go @@ -7,6 +7,7 @@ import ( "time" appconfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/config" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/logs" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/utils" @@ -23,6 +24,7 @@ import ( // SearchResult 是我们最终返回给 LLM 的标准结果格式 type SearchResult struct { MessageID string `json:"message_id"` + OpenID string `json:"user_id"` RawMessage string `json:"raw_message"` UserName string `json:"user_name"` ChatName string `json:"chat_name"` @@ -34,12 +36,14 @@ type SearchResult struct { // HybridSearchRequest 定义了搜索的输入参数 type HybridSearchRequest struct { - QueryText []string `json:"query"` - TopK int `json:"top_k"` - OpenID string `json:"user_id,omitempty"` - ChatID string `json:"chat_id,omitempty"` - StartTime string `json:"start_time,omitempty"` - EndTime string `json:"end_time,omitempty"` + QueryText []string `json:"query"` + TopK int `json:"top_k"` + OpenID string `json:"user_id,omitempty"` + UserName string `json:"user_name,omitempty"` + ChatID string `json:"chat_id,omitempty"` + MessageType string `json:"message_type,omitempty"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` } type EmbeddingFunc func(ctx context.Context, text string) (vector []float32, tokenUsage model.Usage, err error) @@ -50,45 +54,25 @@ func HybridSearch(ctx context.Context, req HybridSearchRequest, embeddingFunc Em defer span.End() defer func() { otel.RecordError(span, err) }() - logs.L().Ctx(ctx).Info("开始混合搜索", zap.String("query_text", strings.Join(req.QueryText, " "))) + logs.L().Ctx(ctx).Info("开始混合搜索", zap.String("req", utils.MustMarshalString(req))) if req.TopK <= 0 { req.TopK = 5 } - // --- A. 构建过滤 (Filter) 子句 --- - // 'filter' 用于精确匹配,不影响评分,例如过滤 user_id - filters := []map[string]interface{}{} - if req.OpenID != "" { - filters = append(filters, map[string]interface{}{"term": map[string]interface{}{"user_id": req.OpenID}}) - } - if req.ChatID != "" { - filters = append(filters, map[string]interface{}{"term": map[string]interface{}{"chat_id": req.ChatID}}) - } - - if req.StartTime != "" { - if parseStartTime := parseTimeFormat(req.StartTime, time.DateTime); !parseStartTime.IsErr() { - filters = append(filters, map[string]interface{}{"range": map[string]interface{}{"create_time_v2": map[string]interface{}{"gte": parseStartTime.Value().Format(time.RFC3339)}}}) - } else { - filters = append(filters, map[string]interface{}{"range": map[string]interface{}{"create_time_v2": map[string]interface{}{"gte": time.Now().Add(-1 * time.Hour * 24 * 7).Format(time.RFC3339)}}}) - } - } - if req.EndTime != "" { - if parseEndTime := parseTimeFormat(req.EndTime, time.DateTime); !parseEndTime.IsErr() { - filters = append(filters, map[string]interface{}{"range": map[string]interface{}{"create_time_v2": map[string]interface{}{"lte": parseEndTime.Value().Format(time.RFC3339)}}}) - } else { - filters = append(filters, map[string]interface{}{"range": map[string]interface{}{"create_time_v2": map[string]interface{}{"lte": time.Now().Add(-1 * time.Hour * 24 * 7).Format(time.RFC3339)}}}) - } - } - queryTerms := make([]string, 0) jieba := gojieba.NewJieba() defer jieba.Free() for _, query := range req.QueryText { - queryTerms = append(queryTerms, jieba.Cut(query, true)...) + if trimmed := strings.TrimSpace(query); trimmed != "" { + queryTerms = append(queryTerms, jieba.Cut(trimmed, true)...) + } } queryVecList := make([]map[string]any, 0, len(req.QueryText)) for _, query := range req.QueryText { + if strings.TrimSpace(query) == "" { + continue + } var queryVec []float32 queryVec, _, err = embeddingFunc(ctx, query) if err != nil { @@ -106,34 +90,9 @@ func HybridSearch(ctx context.Context, req HybridSearchRequest, embeddingFunc Em }) } - shouldClauses := []map[string]interface{}{ - { - // 1. 关键词 (BM25) 查询 - // 我们查询 'message_str' 字段 - "terms": map[string]interface{}{"raw_message_jieba_array": queryTerms}, - }, - { - "bool": map[string]any{"should": queryVecList}, - }, - } - query := map[string]interface{}{ - "size": req.TopK, - "_source": []string{ // 只拉取我们需要的字段 - "message_id", - "raw_message", - "user_name", - "chat_name", - "create_time", - "create_time_v2", - "mentions", - }, - "query": map[string]interface{}{ - "bool": map[string]interface{}{ - "must": filters, // 必须满足的过滤条件 - "should": shouldClauses, // 应该满足的召回条件 - "minimum_should_match": 1, // 至少匹配 'should' 中的一个 - }, - }, + query, err := buildHybridSearchQuery(req, queryTerms, queryVecList, time.Now()) + if err != nil { + return nil, err } span.SetAttributes(attribute.Key("query").String(utils.MustMarshalString(query))) res, err := opensearch.SearchData(ctx, appconfig.GetLarkMsgIndex(ctx, req.ChatID, req.OpenID), query) @@ -153,6 +112,7 @@ func HybridSearch(ctx context.Context, req HybridSearchRequest, embeddingFunc Em logs.L().Ctx(ctx).Warn("解析 mentions 失败", zap.Error(err), zap.String("mentions", result.Mentions)) continue } + normalizeSearchResultActor(result) result.RawMessage = ReplaceMentionToName(result.RawMessage, mentions) resultList = append(resultList, result) } @@ -163,3 +123,121 @@ func HybridSearch(ctx context.Context, req HybridSearchRequest, embeddingFunc Em func parseTimeFormat(s, fmt string) gresult.R[time.Time] { return gresult.Of(time.Parse(fmt, s)) } + +func buildHybridSearchQuery(req HybridSearchRequest, queryTerms []string, queryVecList []map[string]any, now time.Time) (map[string]any, error) { + filters, err := buildHybridSearchFilters(req, now) + if err != nil { + return nil, err + } + + shouldClauses := make([]map[string]any, 0, 2) + if len(queryTerms) > 0 { + shouldClauses = append(shouldClauses, map[string]any{ + "terms": map[string]any{"raw_message_jieba_array": queryTerms}, + }) + } + if len(queryVecList) > 0 { + shouldClauses = append(shouldClauses, map[string]any{ + "bool": map[string]any{"should": queryVecList}, + }) + } + + boolQuery := map[string]any{ + "must": filters, + } + if len(shouldClauses) > 0 { + boolQuery["should"] = shouldClauses + boolQuery["minimum_should_match"] = 1 + } + + return map[string]any{ + "size": req.TopK, + "_source": []string{ + "message_id", + "user_id", + "raw_message", + "user_name", + "chat_name", + "create_time", + "create_time_v2", + "mentions", + }, + "query": map[string]any{ + "bool": boolQuery, + }, + }, nil +} + +func buildHybridSearchFilters(req HybridSearchRequest, now time.Time) ([]map[string]any, error) { + chatID := strings.TrimSpace(req.ChatID) + if chatID == "" { + return nil, fmt.Errorf("chat_id is required for scoped history search") + } + if !hasHybridSearchSelector(req) { + return nil, fmt.Errorf("at least one history selector is required") + } + + filters := []map[string]any{ + {"term": map[string]any{"chat_id": chatID}}, + } + if req.OpenID != "" { + openID := strings.TrimSpace(req.OpenID) + currentBot := botidentity.Current() + if currentBot.BotOpenID != "" && openID == currentBot.BotOpenID { + filters = append(filters, map[string]any{"terms": map[string]any{"user_id": []string{openID, "你"}}}) + } else { + filters = append(filters, map[string]any{"term": map[string]any{"user_id": openID}}) + } + } + if trimmed := strings.TrimSpace(req.UserName); trimmed != "" { + filters = append(filters, map[string]any{"term": map[string]any{"user_name": trimmed}}) + } + if trimmed := strings.TrimSpace(req.MessageType); trimmed != "" { + filters = append(filters, map[string]any{"term": map[string]any{"message_type": trimmed}}) + } + + if req.StartTime != "" { + if parseStartTime := parseTimeFormat(req.StartTime, time.DateTime); !parseStartTime.IsErr() { + filters = append(filters, map[string]any{"range": map[string]any{"create_time_v2": map[string]any{"gte": parseStartTime.Value().Format(time.RFC3339)}}}) + } else { + filters = append(filters, map[string]any{"range": map[string]any{"create_time_v2": map[string]any{"gte": now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)}}}) + } + } + if req.EndTime != "" { + if parseEndTime := parseTimeFormat(req.EndTime, time.DateTime); !parseEndTime.IsErr() { + filters = append(filters, map[string]any{"range": map[string]any{"create_time_v2": map[string]any{"lte": parseEndTime.Value().Format(time.RFC3339)}}}) + } else { + filters = append(filters, map[string]any{"range": map[string]any{"create_time_v2": map[string]any{"lte": now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)}}}) + } + } + return filters, nil +} + +func hasHybridSearchSelector(req HybridSearchRequest) bool { + for _, query := range req.QueryText { + if strings.TrimSpace(query) != "" { + return true + } + } + return strings.TrimSpace(req.OpenID) != "" || + strings.TrimSpace(req.UserName) != "" || + strings.TrimSpace(req.MessageType) != "" || + strings.TrimSpace(req.StartTime) != "" || + strings.TrimSpace(req.EndTime) != "" +} + +func normalizeSearchResultActor(result *SearchResult) { + if result == nil { + return + } + currentBot := botidentity.Current() + if currentBot.BotOpenID == "" { + return + } + if strings.TrimSpace(result.OpenID) == "你" { + result.OpenID = currentBot.BotOpenID + } + if strings.TrimSpace(result.OpenID) == currentBot.BotOpenID { + result.UserName = "你" + } +} diff --git a/internal/application/lark/history/search_test.go b/internal/application/lark/history/search_test.go new file mode 100644 index 0000000..bd364ae --- /dev/null +++ b/internal/application/lark/history/search_test.go @@ -0,0 +1,164 @@ +package history + +import ( + "path/filepath" + "testing" + "time" + + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" +) + +func TestBuildHybridSearchQueryRequiresChatID(t *testing.T) { + _, err := buildHybridSearchQuery( + HybridSearchRequest{QueryText: []string{"机器人"}, TopK: 3}, + []string{"机器人"}, + nil, + time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC), + ) + if err == nil { + t.Fatal("expected chat_id requirement error") + } +} + +func TestBuildHybridSearchQueryIncludesMetadataFilters(t *testing.T) { + query, err := buildHybridSearchQuery( + HybridSearchRequest{ + QueryText: []string{"机器人"}, + TopK: 7, + ChatID: "oc_test_chat", + OpenID: "ou_test_user", + UserName: "Alice", + MessageType: "text", + StartTime: "2026-03-20 08:00:00", + EndTime: "2026-03-21 08:00:00", + }, + []string{"机器人"}, + []map[string]any{ + { + "knn": map[string]any{ + "message": map[string]any{"vector": []float32{0.1, 0.2}, "k": 7, "boost": 2.0}, + }, + }, + }, + time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC), + ) + if err != nil { + t.Fatalf("buildHybridSearchQuery() error = %v", err) + } + + if got := query["size"]; got != 7 { + t.Fatalf("size = %v, want 7", got) + } + sourceFields, ok := query["_source"].([]string) + if !ok { + t.Fatalf("_source = %#v, want []string", query["_source"]) + } + if !containsString(sourceFields, "user_id") { + t.Fatalf("_source = %#v, want contain user_id", sourceFields) + } + + boolQuery, ok := query["query"].(map[string]any)["bool"].(map[string]any) + if !ok { + t.Fatalf("bool query missing: %+v", query["query"]) + } + mustClauses, ok := boolQuery["must"].([]map[string]any) + if !ok { + t.Fatalf("must clauses missing: %+v", boolQuery["must"]) + } + if !containsTermFilter(mustClauses, "chat_id", "oc_test_chat") { + t.Fatalf("must clauses missing chat_id filter: %+v", mustClauses) + } + if !containsTermFilter(mustClauses, "user_id", "ou_test_user") { + t.Fatalf("must clauses missing user_id filter: %+v", mustClauses) + } + if !containsTermFilter(mustClauses, "user_name", "Alice") { + t.Fatalf("must clauses missing user_name filter: %+v", mustClauses) + } + if !containsTermFilter(mustClauses, "message_type", "text") { + t.Fatalf("must clauses missing message_type filter: %+v", mustClauses) + } + if !containsRangeFilter(mustClauses, "create_time_v2", "gte") { + t.Fatalf("must clauses missing gte create_time_v2 range: %+v", mustClauses) + } + if !containsRangeFilter(mustClauses, "create_time_v2", "lte") { + t.Fatalf("must clauses missing lte create_time_v2 range: %+v", mustClauses) + } + + shouldClauses, ok := boolQuery["should"].([]map[string]any) + if !ok { + t.Fatalf("should clauses missing: %+v", boolQuery["should"]) + } + if len(shouldClauses) != 2 { + t.Fatalf("should clauses = %d, want 2", len(shouldClauses)) + } +} + +func TestReplaceMentionToNameRestoresMentionAndMarksBotSelf(t *testing.T) { + useHistorySearchConfigPath(t) + selfOpenID := botidentity.Current().BotOpenID + + got := ReplaceMentionToName("提醒 看下", []*Mention{ + { + Key: "", + Name: "旧机器人昵称", + ID: struct { + LegacyUserID string `json:"user_id"` + OpenID string `json:"open_id"` + UnionID string `json:"union_id"` + }{ + OpenID: selfOpenID, + }, + }, + }) + if got != "提醒 @你 看下" { + t.Fatalf("ReplaceMentionToName() = %q, want %q", got, "提醒 @你 看下") + } +} + +func useHistorySearchConfigPath(t *testing.T) { + t.Helper() + configPath, err := filepath.Abs("../../../../.dev/config.toml") + if err != nil { + t.Fatalf("resolve config path: %v", err) + } + t.Setenv("BETAGO_CONFIG_PATH", configPath) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func containsTermFilter(filters []map[string]any, field, value string) bool { + for _, filter := range filters { + term, ok := filter["term"].(map[string]any) + if !ok { + continue + } + if got, ok := term[field]; ok && got == value { + return true + } + } + return false +} + +func containsRangeFilter(filters []map[string]any, field, operator string) bool { + for _, filter := range filters { + ranges, ok := filter["range"].(map[string]any) + if !ok { + continue + } + fieldRange, ok := ranges[field].(map[string]any) + if !ok { + continue + } + if _, ok := fieldRange[operator]; ok { + return true + } + } + return false +} diff --git a/internal/application/lark/history/utils.go b/internal/application/lark/history/utils.go index 3c18217..a52e866 100644 --- a/internal/application/lark/history/utils.go +++ b/internal/application/lark/history/utils.go @@ -3,6 +3,8 @@ package history import ( "fmt" "strings" + + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/botidentity" ) func TagText(text string, color string) string { @@ -23,9 +25,19 @@ type Mention struct { // ReplaceMentionToName 将@user_1 替换成 name func ReplaceMentionToName(input string, mentions []*Mention) string { if mentions != nil { + currentBot := botidentity.Current() for _, mention := range mentions { - // input = strings.ReplaceAll(input, mention.Key, fmt.Sprintf("%s", mention.ID.LegacyUserID, mention.Name)) - input = strings.ReplaceAll(input, mention.Key, "") + if mention == nil || mention.Key == "" { + continue + } + displayName := strings.TrimSpace(mention.Name) + if currentBot.BotOpenID != "" && strings.TrimSpace(mention.ID.OpenID) == currentBot.BotOpenID { + displayName = "你" + } + if displayName == "" { + displayName = strings.TrimSpace(mention.ID.OpenID) + } + input = strings.ReplaceAll(input, mention.Key, fmt.Sprintf("@%s", displayName)) if len(input) > 0 && string(input[0]) == "/" { if inputs := strings.Split(input, " "); len(inputs) > 0 { input = strings.Join(inputs[1:], " ") diff --git a/internal/application/lark/intent/recognizer.go b/internal/application/lark/intent/recognizer.go index 98bccb8..9e54338 100644 --- a/internal/application/lark/intent/recognizer.go +++ b/internal/application/lark/intent/recognizer.go @@ -37,6 +37,15 @@ const ( SuggestActionIgnore = intentmeta.SuggestActionIgnore ) +type ReplyMode = intentmeta.ReplyMode + +const ( + ReplyModeDirect = intentmeta.ReplyModeDirect + ReplyModePassiveReply = intentmeta.ReplyModePassiveReply + ReplyModeActiveInterject = intentmeta.ReplyModeActiveInterject + ReplyModeIgnore = intentmeta.ReplyModeIgnore +) + type InteractionMode = intentmeta.InteractionMode const ( @@ -72,7 +81,24 @@ const intentSystemPrompt = `你是一个群聊消息意图分析助手。你的 - repeat: 重复用户的话 - ignore: 忽略 -5. interaction_mode 用于决定消息应该走哪条回复链路: +5. reply_mode 用于判断这条消息属于哪种回复模式: + - direct: 用户明确点名机器人、私聊机器人、或显然在继续跟机器人已有对话;这类是明确想让机器人接话 + - passive_reply: 用户虽然没有直接@机器人,但从内容看,较明显希望有人回答、补充、接续 + - active_interject: 机器人如果发言更像“主动插话”;可以接,但要更克制,通常用户意愿更弱、打扰风险更高 + - ignore: 不建议机器人接话 + +6. user_willingness 表示用户此刻主观上有多希望机器人接话,范围 0-100: + - 90-100: 明显在等机器人回答 + - 60-89: 大概率欢迎机器人接话 + - 30-59: 可回可不回 + - 0-29: 不太像在等机器人说话 + +7. interrupt_risk 表示如果机器人现在插话,打扰感有多强,范围 0-100: + - 0-20: 基本不打扰 + - 21-60: 需要克制判断 + - 61-100: 很可能显得突兀或打断别人 + +8. interaction_mode 用于决定消息应该走哪条回复链路: - agentic: 明确要完成任务、需要多步规划或能力编排、很可能触发审批/等待回调/等待 schedule/持续跟进 - 分析类任务不是单点事实问答。如果用户要求你综合多方信息、资料、上下文、历史数据、公开信息或工具结果,再去分析原因、研判趋势、给出归因/框架/结论,这类请求优先判定为 agentic - 即使用户只发来一句话,只要任务本质上是研究、调查、归因、比较多个因素、汇总多来源信息后再回答,也应该判定为 agentic @@ -88,7 +114,11 @@ const intentSystemPrompt = `你是一个群聊消息意图分析助手。你的 - standard: 单轮问答、寒暄、解释、轻聊天、简单追问、单点事实查询。 -6. reasoning_effort 用于给后续 agentic 对话提供思考深度建议: +9. needs_history: true/false。只有当你认为需要搜索当前会话历史,才能更稳妥地判断或回答时,才设为 true。 + +10. needs_web: true/false。只有当问题依赖实时/外部事实、公开资料或联网检索时,才设为 true。 + +11. reasoning_effort 用于给后续 agentic 对话提供思考深度建议: - minimal: 几乎不需要推理,简单接话或直接执行单步任务 - low: 需要少量分析,任务较明确 - medium: 需要多步分析或权衡,是默认的 agentic 深度 @@ -103,7 +133,12 @@ const intentSystemPrompt = `你是一个群聊消息意图分析助手。你的 "reply_confidence": 85, "reason": "用户在提问,需要回答", "suggest_action": "chat|react|repeat|ignore", + "reply_mode": "direct|passive_reply|active_interject|ignore", + "user_willingness": 90, + "interrupt_risk": 10, "interaction_mode": "standard|agentic", + "needs_history": true/false, + "needs_web": true/false, "reasoning_effort": "minimal|low|medium|high" }` @@ -137,9 +172,9 @@ func analyzeMessage(ctx context.Context, message, modelID string) (analysis *Int Type: responses.TextType_json_object, }, }, - // Reasoning: &responses.ResponsesReasoning{ - // Effort: responses.ReasoningEffort_minimal, - // }, + Reasoning: &responses.ResponsesReasoning{ + Effort: responses.ReasoningEffort_minimal, + }, Thinking: &responses.ResponsesThinking{ Type: gptr.Of(responses.ThinkingType_disabled), }, @@ -168,7 +203,12 @@ func analyzeMessage(ctx context.Context, message, modelID string) (analysis *Int attribute.Key("need_reply").Bool(analysis.NeedReply), attribute.Key("reply_confidence").Int(analysis.ReplyConfidence), attribute.Key("suggest_action").String(string(analysis.SuggestAction)), + attribute.Key("reply_mode").String(string(analysis.ReplyMode)), + attribute.Key("user_willingness").Int(analysis.UserWillingness), + attribute.Key("interrupt_risk").Int(analysis.InterruptRisk), attribute.Key("interaction_mode").String(string(analysis.InteractionMode)), + attribute.Key("needs_history").Bool(analysis.NeedsHistory), + attribute.Key("needs_web").Bool(analysis.NeedsWeb), attribute.Key("recommended_reasoning_effort").String(analysis.ReasoningEffort.String()), ) @@ -177,7 +217,12 @@ func analyzeMessage(ctx context.Context, message, modelID string) (analysis *Int zap.Bool("need_reply", analysis.NeedReply), zap.Int("confidence", analysis.ReplyConfidence), zap.String("reason", analysis.Reason), + zap.String("reply_mode", string(analysis.ReplyMode)), + zap.Int("user_willingness", analysis.UserWillingness), + zap.Int("interrupt_risk", analysis.InterruptRisk), zap.String("interaction_mode", string(analysis.InteractionMode)), + zap.Bool("needs_history", analysis.NeedsHistory), + zap.Bool("needs_web", analysis.NeedsWeb), zap.String("reasoning_effort", analysis.ReasoningEffort.String()), ) diff --git a/internal/application/lark/intent/recognizer_test.go b/internal/application/lark/intent/recognizer_test.go index 0ea4df9..4f89a77 100644 --- a/internal/application/lark/intent/recognizer_test.go +++ b/internal/application/lark/intent/recognizer_test.go @@ -15,6 +15,11 @@ func TestIntentSystemPromptMarksResearchAndAnalysisTasksAsAgentic(t *testing.T) "是否需要综合多来源信息", "是否需要归因、比较多个因素", "是否预期会触发工具检索", + "reply_mode 用于判断这条消息属于哪种回复模式", + "user_willingness 表示用户此刻主观上有多希望机器人接话", + "interrupt_risk 表示如果机器人现在插话,打扰感有多强", + "needs_history: true/false", + "needs_web: true/false", "“金价今天多少”更接近 standard", "“综合各方信息资源,帮我分析金价剧烈波动的主要原因”更接近 agentic", } @@ -95,6 +100,37 @@ func TestAnalyzeMessageUsesSinglePassAndParsesReasoningEffort(t *testing.T) { } } +func TestAnalyzeMessageParsesReplyModeAndRetrievalHints(t *testing.T) { + oldResponseTextWithCacheFn := responseTextWithCacheFn + defer func() { + responseTextWithCacheFn = oldResponseTextWithCacheFn + }() + + responseTextWithCacheFn = func(ctx context.Context, req ark_dal.CachedResponseRequest) (string, error) { + return `{"intent_type":"chat","need_reply":true,"reply_confidence":72,"reason":"用户明确要继续追问历史上下文","suggest_action":"chat","interaction_mode":"standard","reply_mode":"passive_reply","user_willingness":88,"interrupt_risk":12,"needs_history":true,"needs_web":false}`, nil + } + + analysis, err := analyzeMessage(context.Background(), "把刚才讨论的方案接着说完", "intent-lite") + if err != nil { + t.Fatalf("analyzeMessage() error = %v", err) + } + if analysis.ReplyMode != ReplyModePassiveReply { + t.Fatalf("ReplyMode = %q, want %q", analysis.ReplyMode, ReplyModePassiveReply) + } + if analysis.UserWillingness != 88 { + t.Fatalf("UserWillingness = %d, want 88", analysis.UserWillingness) + } + if analysis.InterruptRisk != 12 { + t.Fatalf("InterruptRisk = %d, want 12", analysis.InterruptRisk) + } + if !analysis.NeedsHistory { + t.Fatal("NeedsHistory should be true") + } + if analysis.NeedsWeb { + t.Fatal("NeedsWeb should be false") + } +} + func TestAnalyzeMessageSanitizesInvalidReasoningEffortForStandardMode(t *testing.T) { oldResponseTextWithCacheFn := responseTextWithCacheFn defer func() { @@ -124,6 +160,40 @@ func TestAnalyzeMessageSanitizesInvalidReasoningEffortForStandardMode(t *testing } } +func TestAnalyzeMessageSanitizesInvalidReplyMetadata(t *testing.T) { + oldResponseTextWithCacheFn := responseTextWithCacheFn + defer func() { + responseTextWithCacheFn = oldResponseTextWithCacheFn + }() + + responseTextWithCacheFn = func(ctx context.Context, req ark_dal.CachedResponseRequest) (string, error) { + return `{"intent_type":"share","need_reply":true,"reply_confidence":130,"reason":"也许可以插一句","suggest_action":"chat","interaction_mode":"standard","reply_mode":"surprise","user_willingness":180,"interrupt_risk":-3}`, nil + } + + analysis, err := analyzeMessage(context.Background(), "我刚看到一条新闻", "intent-lite") + if err != nil { + t.Fatalf("analyzeMessage() error = %v", err) + } + if analysis.ReplyConfidence != 100 { + t.Fatalf("ReplyConfidence = %d, want 100", analysis.ReplyConfidence) + } + if analysis.ReplyMode != ReplyModePassiveReply { + t.Fatalf("ReplyMode = %q, want %q", analysis.ReplyMode, ReplyModePassiveReply) + } + if analysis.UserWillingness != 100 { + t.Fatalf("UserWillingness = %d, want 100", analysis.UserWillingness) + } + if analysis.InterruptRisk != 0 { + t.Fatalf("InterruptRisk = %d, want 0", analysis.InterruptRisk) + } + if analysis.NeedsHistory { + t.Fatal("NeedsHistory should default to false") + } + if analysis.NeedsWeb { + t.Fatal("NeedsWeb should default to false") + } +} + func TestAnalyzeMessageSanitizesInvalidReasoningEffortForAgenticMode(t *testing.T) { oldResponseTextWithCacheFn := responseTextWithCacheFn defer func() { diff --git a/internal/application/lark/intentmeta/types.go b/internal/application/lark/intentmeta/types.go index 99790cd..304e087 100644 --- a/internal/application/lark/intentmeta/types.go +++ b/internal/application/lark/intentmeta/types.go @@ -28,6 +28,15 @@ const ( SuggestActionIgnore SuggestAction = "ignore" ) +type ReplyMode string + +const ( + ReplyModeDirect ReplyMode = "direct" + ReplyModePassiveReply ReplyMode = "passive_reply" + ReplyModeActiveInterject ReplyMode = "active_interject" + ReplyModeIgnore ReplyMode = "ignore" +) + type InteractionMode string const ( @@ -52,6 +61,11 @@ type IntentAnalysis struct { Reason string `json:"reason"` SuggestAction SuggestAction `json:"suggest_action"` InteractionMode InteractionMode `json:"interaction_mode"` + ReplyMode ReplyMode `json:"reply_mode"` + UserWillingness int `json:"user_willingness"` + InterruptRisk int `json:"interrupt_risk"` + NeedsHistory bool `json:"needs_history"` + NeedsWeb bool `json:"needs_web"` ReasoningEffort responses.ReasoningEffort_Enum `json:"reasoning_effort"` } @@ -64,6 +78,11 @@ func (a *IntentAnalysis) UnmarshalJSON(data []byte) error { Reason string `json:"reason"` SuggestAction SuggestAction `json:"suggest_action"` InteractionMode InteractionMode `json:"interaction_mode"` + ReplyMode ReplyMode `json:"reply_mode"` + UserWillingness int `json:"user_willingness"` + InterruptRisk int `json:"interrupt_risk"` + NeedsHistory bool `json:"needs_history"` + NeedsWeb bool `json:"needs_web"` ReasoningEffort json.RawMessage `json:"reasoning_effort"` } if err := json.Unmarshal(data, &raw); err != nil { @@ -77,6 +96,11 @@ func (a *IntentAnalysis) UnmarshalJSON(data []byte) error { Reason: raw.Reason, SuggestAction: raw.SuggestAction, InteractionMode: raw.InteractionMode, + ReplyMode: raw.ReplyMode, + UserWillingness: raw.UserWillingness, + InterruptRisk: raw.InterruptRisk, + NeedsHistory: raw.NeedsHistory, + NeedsWeb: raw.NeedsWeb, ReasoningEffort: parseReasoningEffort(raw.ReasoningEffort), } return nil @@ -129,12 +153,50 @@ func (a *IntentAnalysis) Sanitize() { if a.ReplyConfidence > 100 { a.ReplyConfidence = 100 } + if a.UserWillingness < 0 { + a.UserWillingness = 0 + } + if a.UserWillingness > 100 { + a.UserWillingness = 100 + } + if a.InterruptRisk < 0 { + a.InterruptRisk = 0 + } + if a.InterruptRisk > 100 { + a.InterruptRisk = 100 + } if a.IntentType == IntentTypeQuestion { a.NeedReply = true } else if a.IntentType == IntentTypeIgnore { a.NeedReply = false } + + switch a.ReplyMode { + case ReplyModeDirect, ReplyModePassiveReply, ReplyModeActiveInterject, ReplyModeIgnore: + default: + a.ReplyMode = defaultReplyMode(a.NeedReply) + } + + if !a.NeedReply { + a.ReplyMode = ReplyModeIgnore + } + + switch a.ReplyMode { + case ReplyModeDirect: + a.NeedReply = true + a.UserWillingness = 100 + a.InterruptRisk = 0 + case ReplyModeIgnore: + a.NeedReply = false + } +} + +func defaultReplyMode(needReply bool) ReplyMode { + if needReply { + return ReplyModePassiveReply + } + return ReplyModeIgnore } func parseReasoningEffort(raw json.RawMessage) responses.ReasoningEffort_Enum { diff --git a/internal/application/lark/messages/ops/chat_op.go b/internal/application/lark/messages/ops/chat_op.go index abe0a44..0fe995b 100644 --- a/internal/application/lark/messages/ops/chat_op.go +++ b/internal/application/lark/messages/ops/chat_op.go @@ -83,6 +83,7 @@ func (r *ChatMsgOperator) Run(ctx context.Context, event *larkim.P2MessageReceiv defer otel.RecordErrorPtr(span, &err) chatID := *event.Event.Message.ChatId + openID := messageOpenID(event, meta) decider := ratelimit.GetDecider() observation, ok := observeRuntimeMessage(ctx, event, meta) if ok && shouldDirectRouteRuntime(observation, agentruntime.TriggerTypeFollowUp, agentruntime.TriggerTypeReplyToBot) { @@ -97,14 +98,18 @@ func (r *ChatMsgOperator) Run(ctx context.Context, event *larkim.P2MessageReceiv // 优先尝试使用意图识别结果 if analysis, ok := GetIntentAnalysisFromMeta(meta); ok { // 使用频控决策器决定是否回复 - decision := decider.DecideIntentReply(ctx, chatID, analysis) + decision := decider.DecideIntentReply(ctx, chatID, openID, analysis) if decision.Allowed { // 先记录,再回复;因为回复的时延可能高的一批。。 - decider.RecordReply(ctx, chatID, ratelimit.TriggerTypeIntent) + decider.RecordReply(ctx, chatID, decision.TriggerType) logs.L().Ctx(ctx).Info("decided to reply by intent recognition with rate limit", zap.String("intent_type", string(analysis.IntentType)), zap.Int("confidence", analysis.ReplyConfidence), + zap.String("reply_mode", string(analysis.ReplyMode)), + zap.Int("user_willingness", analysis.UserWillingness), + zap.Int("interrupt_risk", analysis.InterruptRisk), zap.String("reason", analysis.Reason), + zap.String("trigger_type", string(decision.TriggerType)), zap.String("ratelimit_reason", decision.Reason), ) // sendMsg @@ -118,6 +123,10 @@ func (r *ChatMsgOperator) Run(ctx context.Context, event *larkim.P2MessageReceiv zap.String("intent_type", string(analysis.IntentType)), zap.Bool("need_reply", analysis.NeedReply), zap.Int("confidence", analysis.ReplyConfidence), + zap.String("reply_mode", string(analysis.ReplyMode)), + zap.Int("user_willingness", analysis.UserWillingness), + zap.Int("interrupt_risk", analysis.InterruptRisk), + zap.String("trigger_type", string(decision.TriggerType)), zap.String("ratelimit_reason", decision.Reason), ) // 意图识别说不需要回复或被频控拒绝,直接返回 diff --git a/internal/application/lark/messages/ops/intent_recognize_op.go b/internal/application/lark/messages/ops/intent_recognize_op.go index d525720..b09b468 100644 --- a/internal/application/lark/messages/ops/intent_recognize_op.go +++ b/internal/application/lark/messages/ops/intent_recognize_op.go @@ -112,6 +112,13 @@ func (r *IntentRecognizeOperator) Fetch(ctx context.Context, event *larkim.P2Mes observed, eligible, ) + if shouldForceDirectReplyMode(event, observation, observed) { + analysis.ReplyMode = intent.ReplyModeDirect + analysis.NeedReply = true + analysis.UserWillingness = 100 + analysis.InterruptRisk = 0 + } + analysis.Sanitize() if err := storeIntentAnalysis(meta, analysis); err != nil { logs.L().Ctx(ctx).Error("failed to store intent analysis", zap.Error(err)) @@ -211,6 +218,9 @@ func continuationIntentAnalysis(observation agentruntime.ShadowObservation) *int ReplyConfidence: 100, Reason: strings.TrimSpace(observation.Reason), SuggestAction: intent.SuggestActionChat, + ReplyMode: intent.ReplyModeDirect, + UserWillingness: 100, + InterruptRisk: 0, InteractionMode: intent.InteractionModeAgentic, ReasoningEffort: intent.DefaultReasoningEffort(intent.InteractionModeAgentic), } @@ -242,6 +252,31 @@ func shouldUseIntentDrivenInteractionMode( return observed && observation.TriggerType == agentruntime.TriggerTypeReplyToBot } +func shouldForceDirectReplyMode( + event *larkim.P2MessageReceiveV1, + observation agentruntime.ShadowObservation, + observed bool, +) bool { + if strings.EqualFold(currentChatType(event), "p2p") { + return true + } + if runtimeIsMentioned(event) { + return true + } + if !observed { + return false + } + switch observation.TriggerType { + case agentruntime.TriggerTypeMention, + agentruntime.TriggerTypeReplyToBot, + agentruntime.TriggerTypeFollowUp, + agentruntime.TriggerTypeP2P: + return true + default: + return false + } +} + // interactionModeFromChatMode maps the configured chat mode to the seeded interaction mode. func interactionModeFromChatMode(mode appconfig.ChatMode) intent.InteractionMode { if mode.Normalize() == appconfig.ChatModeAgentic { diff --git a/internal/application/lark/messages/ops/intent_recognize_op_test.go b/internal/application/lark/messages/ops/intent_recognize_op_test.go index ce0241a..b88de44 100644 --- a/internal/application/lark/messages/ops/intent_recognize_op_test.go +++ b/internal/application/lark/messages/ops/intent_recognize_op_test.go @@ -65,6 +65,15 @@ func TestIntentRecognizeOperatorFetchSkipsAnalyzerForContinuation(t *testing.T) if analysis.Reason != "attach_follow_up" { t.Fatalf("Reason = %q, want %q", analysis.Reason, "attach_follow_up") } + if analysis.ReplyMode != intent.ReplyModeDirect { + t.Fatalf("ReplyMode = %q, want %q", analysis.ReplyMode, intent.ReplyModeDirect) + } + if analysis.UserWillingness != 100 { + t.Fatalf("UserWillingness = %d, want 100", analysis.UserWillingness) + } + if analysis.InterruptRisk != 0 { + t.Fatalf("InterruptRisk = %d, want 0", analysis.InterruptRisk) + } if analysis.ReasoningEffort != responses.ReasoningEffort_medium { t.Fatalf("ReasoningEffort = %v, want %v", analysis.ReasoningEffort, responses.ReasoningEffort_medium) } @@ -97,6 +106,9 @@ func TestIntentRecognizeOperatorFetchUsesAnalyzerForNonContinuationMessage(t *te Reason: "模型判断复杂请求", SuggestAction: intent.SuggestActionChat, InteractionMode: intent.InteractionModeAgentic, + ReplyMode: intent.ReplyModePassiveReply, + UserWillingness: 72, + InterruptRisk: 21, ReasoningEffort: responses.ReasoningEffort_high, }, nil }, @@ -118,6 +130,15 @@ func TestIntentRecognizeOperatorFetchUsesAnalyzerForNonContinuationMessage(t *te if analysis.InteractionMode != intent.InteractionModeAgentic { t.Fatalf("InteractionMode = %q, want %q", analysis.InteractionMode, intent.InteractionModeAgentic) } + if analysis.ReplyMode != intent.ReplyModeDirect { + t.Fatalf("ReplyMode = %q, want %q", analysis.ReplyMode, intent.ReplyModeDirect) + } + if analysis.UserWillingness != 100 { + t.Fatalf("UserWillingness = %d, want 100", analysis.UserWillingness) + } + if analysis.InterruptRisk != 0 { + t.Fatalf("InterruptRisk = %d, want 0", analysis.InterruptRisk) + } if analysis.ReasoningEffort != responses.ReasoningEffort_high { t.Fatalf("ReasoningEffort = %v, want %v", analysis.ReasoningEffort, responses.ReasoningEffort_high) } diff --git a/internal/application/lark/ratelimit/integration.go b/internal/application/lark/ratelimit/integration.go index 09e6ace..defbb24 100644 --- a/internal/application/lark/ratelimit/integration.go +++ b/internal/application/lark/ratelimit/integration.go @@ -3,9 +3,11 @@ package ratelimit import ( "context" "fmt" + "math" "math/rand" "sync" + appconfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/config" "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/intent" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/logs" "go.uber.org/zap" @@ -13,15 +15,20 @@ import ( // Decision 频控决策结果 type Decision struct { - Allowed bool `json:"allowed"` - Reason string `json:"reason"` - ShouldRetry bool `json:"should_retry"` - CooldownSec int `json:"cooldown_sec"` + Allowed bool `json:"allowed"` + Reason string `json:"reason"` + ShouldRetry bool `json:"should_retry"` + CooldownSec int `json:"cooldown_sec"` + TriggerType TriggerType `json:"trigger_type"` } // Decider 频控决策器 type Decider struct { limiter *SmartRateLimiter + + intentReplyThreshold func(context.Context, string, string) int + intentFallbackRate func(context.Context, string, string) int + randomFloat64 func() float64 } // NewDecider 创建频控决策器 @@ -29,15 +36,28 @@ func NewDecider(limiter *SmartRateLimiter) *Decider { if limiter == nil { limiter = Get() } - return &Decider{limiter: limiter} + return &Decider{ + limiter: limiter, + intentReplyThreshold: appconfig.GetIntentReplyThreshold, + intentFallbackRate: appconfig.GetIntentFallbackRate, + randomFloat64: rand.Float64, + } } // DecideIntentReply 决定是否进行意图识别回复 func (d *Decider) DecideIntentReply( ctx context.Context, chatID string, + openID string, analysis *intent.IntentAnalysis, ) *Decision { + if analysis == nil { + return &Decision{ + Allowed: false, + Reason: "意图分析缺失", + } + } + analysis.Sanitize() if !analysis.NeedReply { return &Decision{ Allowed: false, @@ -45,43 +65,130 @@ func (d *Decider) DecideIntentReply( } } - allowed, reason := d.limiter.Allow(ctx, chatID, TriggerTypeIntent) + triggerType := triggerTypeFromIntentAnalysis(analysis) + if triggerType == TriggerTypeMention { + return &Decision{ + Allowed: true, + Reason: "直达消息绕过被动频控", + TriggerType: TriggerTypeMention, + } + } + + allowed, reason := d.limiter.Allow(ctx, chatID, triggerType) if !allowed { return &Decision{ - Allowed: false, - Reason: fmt.Sprintf("频控限制: %s", reason), + Allowed: false, + Reason: fmt.Sprintf("频控限制: %s", reason), + TriggerType: triggerType, } } - // 使用硬编码的默认值(暂时避免循环依赖) - threshold := 70 - if analysis.ReplyConfidence >= threshold { + threshold := d.getIntentReplyThreshold(ctx, chatID, openID) + score := calculateIntentReplyScore(analysis) + if score >= float64(threshold) { return &Decision{ - Allowed: true, - Reason: fmt.Sprintf("高置信度回复 (置信度: %d)", analysis.ReplyConfidence), + Allowed: true, + Reason: fmt.Sprintf("回复评分达阈值 (score: %.1f, threshold: %d)", score, threshold), + TriggerType: triggerType, } } - fallbackRate := 10.0 / 100.0 + fallbackRate := float64(d.getIntentFallbackRate(ctx, chatID, openID)) / 100.0 stats := d.limiter.GetStats(ctx, chatID) - adjustedRate := fallbackRate - if stats.CurrentActivityScore > 0.7 { - adjustedRate = fallbackRate * 1.5 - } else if stats.CurrentActivityScore < 0.3 { - adjustedRate = fallbackRate * 0.5 + adjustedRate := adjustIntentFallbackRate(fallbackRate, stats, analysis, threshold, score) + if adjustedRate < 0 { + adjustedRate = 0 } - - if rand.Float64() < adjustedRate { + if adjustedRate > 1 { + adjustedRate = 1 + } + if d.randomFloat64() < adjustedRate { return &Decision{ - Allowed: true, - Reason: fmt.Sprintf("低置信度概率通过 (置信度: %d, 概率: %.2f)", analysis.ReplyConfidence, adjustedRate), + Allowed: true, + Reason: fmt.Sprintf("低于阈值但通过概率筛选 (score: %.1f, threshold: %d, rate: %.2f)", score, threshold, adjustedRate), + TriggerType: triggerType, } } return &Decision{ - Allowed: false, - Reason: fmt.Sprintf("低置信度且未通过概率筛选 (置信度: %d, 阈值: %d)", analysis.ReplyConfidence, threshold), + Allowed: false, + Reason: fmt.Sprintf("回复评分未达阈值且未通过概率筛选 (score: %.1f, threshold: %d, rate: %.2f)", score, threshold, adjustedRate), + TriggerType: triggerType, + } +} + +func (d *Decider) getIntentReplyThreshold(ctx context.Context, chatID, openID string) int { + if d != nil && d.intentReplyThreshold != nil { + if threshold := d.intentReplyThreshold(ctx, chatID, openID); threshold > 0 { + return threshold + } + } + return 70 +} + +func (d *Decider) getIntentFallbackRate(ctx context.Context, chatID, openID string) int { + if d != nil && d.intentFallbackRate != nil { + if rate := d.intentFallbackRate(ctx, chatID, openID); rate >= 0 { + return rate + } + } + return 10 +} + +func triggerTypeFromIntentAnalysis(analysis *intent.IntentAnalysis) TriggerType { + if analysis == nil { + return TriggerTypeIntent + } + switch analysis.ReplyMode { + case intent.ReplyModeDirect: + return TriggerTypeMention + case intent.ReplyModeActiveInterject: + return TriggerTypeRandom + case intent.ReplyModeIgnore: + return TriggerTypeRandom + default: + return TriggerTypeIntent + } +} + +func calculateIntentReplyScore(analysis *intent.IntentAnalysis) float64 { + if analysis == nil { + return 0 + } + score := float64(analysis.ReplyConfidence)*0.45 + + float64(analysis.UserWillingness)*0.35 + + float64(100-analysis.InterruptRisk)*0.20 + if analysis.ReplyMode == intent.ReplyModeActiveInterject { + score -= 10 + } + return math.Max(0, math.Min(100, score)) +} + +func adjustIntentFallbackRate( + fallbackRate float64, + stats *ChatStats, + analysis *intent.IntentAnalysis, + threshold int, + score float64, +) float64 { + adjustedRate := fallbackRate + if adjustedRate <= 0 { + return 0 + } + if stats != nil { + if stats.CurrentActivityScore > 0.7 { + adjustedRate *= 1.2 + } else if stats.CurrentActivityScore < 0.3 { + adjustedRate *= 0.6 + } + } + if analysis != nil && analysis.ReplyMode == intent.ReplyModeActiveInterject { + adjustedRate *= 0.5 } + if gap := float64(threshold) - score; gap > 0 { + adjustedRate *= math.Max(0.2, 1.0-gap/100.0) + } + return adjustedRate } // DecideRandomReply 决定是否进行随机回复 @@ -93,18 +200,18 @@ func (d *Decider) DecideRandomReply( allowed, reason := d.limiter.Allow(ctx, chatID, TriggerTypeRandom) if !allowed { return &Decision{ - Allowed: false, - Reason: fmt.Sprintf("频控限制: %s", reason), + Allowed: false, + Reason: fmt.Sprintf("频控限制: %s", reason), + TriggerType: TriggerTypeRandom, } } stats := d.limiter.GetStats(ctx, chatID) adjustedProb := baseProbability - - if stats.CurrentActivityScore > 0.8 { + if stats.CurrentActivityScore > 0.7 { adjustedProb = baseProbability * 1.5 } else if stats.CurrentActivityScore > 0.5 { - adjustedProb = baseProbability * 1.2 + adjustedProb = baseProbability * 1.5 } else if stats.CurrentActivityScore < 0.2 { adjustedProb = baseProbability * 0.3 } @@ -116,16 +223,18 @@ func (d *Decider) DecideRandomReply( adjustedProb *= 0.8 } - if rand.Float64() < adjustedProb { + if d.randomFloat64() < adjustedProb { return &Decision{ - Allowed: true, - Reason: fmt.Sprintf("随机概率通过 (原始概率: %.2f, 调整后: %.2f)", baseProbability, adjustedProb), + Allowed: true, + Reason: fmt.Sprintf("随机概率通过 (原始概率: %.2f, 调整后: %.2f)", baseProbability, adjustedProb), + TriggerType: TriggerTypeRandom, } } return &Decision{ - Allowed: false, - Reason: fmt.Sprintf("未通过随机概率筛选 (原始概率: %.2f, 调整后: %.2f)", baseProbability, adjustedProb), + Allowed: false, + Reason: fmt.Sprintf("未通过随机概率筛选 (原始概率: %.2f, 调整后: %.2f)", baseProbability, adjustedProb), + TriggerType: TriggerTypeRandom, } } diff --git a/internal/application/lark/ratelimit/rate_limiter.go b/internal/application/lark/ratelimit/rate_limiter.go index 8459237..dbca5b6 100644 --- a/internal/application/lark/ratelimit/rate_limiter.go +++ b/internal/application/lark/ratelimit/rate_limiter.go @@ -397,12 +397,13 @@ func (m *Metrics) GetAllChatStats() map[string]*ChatMetrics { // SmartRateLimiter 智能频控器 type SmartRateLimiter struct { - config *Config - rdb *redis.Client - metrics *Metrics - localCache map[string]*ChatStats - localCacheMu sync.RWMutex - triggerWeights map[TriggerType]float64 + config *Config + rdb *redis.Client + metrics *Metrics + localCache map[string]*ChatStats + localCacheMu sync.RWMutex + hardTriggerWeights map[TriggerType]float64 + softTriggerWeights map[TriggerType]float64 } // NewSmartRateLimiter 创建智能频控器 @@ -419,12 +420,19 @@ func NewSmartRateLimiter(config *Config, rdb *redis.Client) *SmartRateLimiter { rdb: rdb, metrics: NewMetrics(rdb), localCache: make(map[string]*ChatStats), - triggerWeights: map[TriggerType]float64{ + hardTriggerWeights: map[TriggerType]float64{ TriggerTypeIntent: 1.0, - TriggerTypeRandom: 0.5, + TriggerTypeRandom: 0.6, TriggerTypeReaction: 0.3, TriggerTypeRepeat: 0.7, - TriggerTypeMention: 0.5, + TriggerTypeMention: 0.0, + }, + softTriggerWeights: map[TriggerType]float64{ + TriggerTypeIntent: 1.0, + TriggerTypeRandom: 0.6, + TriggerTypeReaction: 0.3, + TriggerTypeRepeat: 0.7, + TriggerTypeMention: 0.35, }, } } @@ -625,13 +633,8 @@ func (s *SmartRateLimiter) Allow(ctx context.Context, chatID string, triggerType } } - triggerWeight := s.triggerWeights[triggerType] - if triggerWeight == 0 { - triggerWeight = 1.0 - } - // 检查每小时限制 - messages1h := s.countMessagesInWindow(recentSends, now, time.Hour, triggerWeight) + messages1h := s.countMessagesInWindow(recentSends, now, time.Hour, s.hardTriggerWeights) max1h := s.getAdjustedMaxPerHour(stats) if messages1h >= float64(max1h) { // 应用冷却 @@ -645,9 +648,16 @@ func (s *SmartRateLimiter) Allow(ctx context.Context, chatID string, triggerType s.recordCheck(ctx, chatID, string(triggerType), false, reason) return false, reason } + softMessages1h := s.countMessagesInWindow(recentSends, now, time.Hour, s.softTriggerWeights) + softMax1h := s.getSoftLoadMaxPerHour(stats) + if softMessages1h >= softMax1h { + reason = fmt.Sprintf("近1小时软负载 %.2f 超过阈值 %.2f", softMessages1h, softMax1h) + s.recordCheck(ctx, chatID, string(triggerType), false, reason) + return false, reason + } // 检查每天限制 - messages24h := s.countMessagesInWindow(recentSends, now, 24*time.Hour, triggerWeight) + messages24h := s.countMessagesInWindow(recentSends, now, 24*time.Hour, s.hardTriggerWeights) max24h := s.getAdjustedMaxPerDay(stats) if messages24h >= float64(max24h) { // 应用冷却 @@ -663,7 +673,7 @@ func (s *SmartRateLimiter) Allow(ctx context.Context, chatID string, triggerType } // 检查爆发 - burstCount := s.countMessagesInWindow(recentSends, now, time.Duration(s.config.BurstWindowSeconds)*time.Second, 1.0) + burstCount := s.countMessagesInWindow(recentSends, now, time.Duration(s.config.BurstWindowSeconds)*time.Second, nil) if int(burstCount) >= s.config.BurstThreshold { // 应用冷却 newLevel := min(cooldownLevel+1, 5) @@ -746,8 +756,8 @@ func (s *SmartRateLimiter) GetStats(ctx context.Context, chatID string) *ChatSta } func (s *SmartRateLimiter) updateDerivedStats(stats *ChatStats, hourly [24]HourlyStats, recentSends []SendRecord, now time.Time) { - stats.TotalMessages24h = int64(s.countMessagesInWindow(recentSends, now, 24*time.Hour, 1.0)) - stats.TotalMessages1h = int64(s.countMessagesInWindow(recentSends, now, time.Hour, 1.0)) + stats.TotalMessages24h = int64(s.countMessagesInWindow(recentSends, now, 24*time.Hour, nil)) + stats.TotalMessages1h = int64(s.countMessagesInWindow(recentSends, now, time.Hour, nil)) stats.CurrentActivityScore = s.calculateActivityScore(hourly, now) stats.CurrentBurstFactor = s.calculateBurstFactor(recentSends, now) } @@ -780,8 +790,8 @@ func (s *SmartRateLimiter) calculateBurstFactor(recentSends []SendRecord, now ti shortWindow := 5 * time.Minute longWindow := 1 * time.Hour - shortCount := s.countMessagesInWindow(recentSends, now, shortWindow, 1.0) - longCount := s.countMessagesInWindow(recentSends, now, longWindow, 1.0) + shortCount := s.countMessagesInWindow(recentSends, now, shortWindow, nil) + longCount := s.countMessagesInWindow(recentSends, now, longWindow, nil) if longCount == 0 { return 1.0 @@ -794,7 +804,7 @@ func (s *SmartRateLimiter) calculateBurstFactor(recentSends []SendRecord, now ti func (s *SmartRateLimiter) getAdjustedMinInterval(stats *ChatStats, triggerType TriggerType) float64 { baseInterval := s.config.MinIntervalSeconds - triggerWeight := s.triggerWeights[triggerType] + triggerWeight := s.hardWeight(triggerType) if triggerWeight == 0 { triggerWeight = 1.0 } @@ -855,7 +865,11 @@ func (s *SmartRateLimiter) getAdjustedMaxPerDay(stats *ChatStats) int { return int(math.Max(float64(baseMax)*multiplier, 20)) } -func (s *SmartRateLimiter) countMessagesInWindow(recentSends []SendRecord, now time.Time, window time.Duration, weight float64) float64 { +func (s *SmartRateLimiter) getSoftLoadMaxPerHour(stats *ChatStats) float64 { + return float64(s.getAdjustedMaxPerHour(stats)) * 1.35 +} + +func (s *SmartRateLimiter) countMessagesInWindow(recentSends []SendRecord, now time.Time, window time.Duration, weights map[TriggerType]float64) float64 { count := 0.0 cutoff := now.Add(-window) @@ -863,16 +877,28 @@ func (s *SmartRateLimiter) countMessagesInWindow(recentSends []SendRecord, now t if send.Timestamp.Before(cutoff) { break } - triggerWeight := s.triggerWeights[send.TriggerType] - if triggerWeight == 0 { - triggerWeight = 1.0 - } - count += weight * triggerWeight + triggerWeight := weightFor(weights, send.TriggerType) + count += triggerWeight } return count } +func (s *SmartRateLimiter) hardWeight(triggerType TriggerType) float64 { + return weightFor(s.hardTriggerWeights, triggerType) +} + +func weightFor(weights map[TriggerType]float64, triggerType TriggerType) float64 { + if weights == nil { + return 1.0 + } + triggerWeight, ok := weights[triggerType] + if !ok { + return 1.0 + } + return triggerWeight +} + // ========================================== // 全局单例 // ========================================== diff --git a/internal/application/lark/ratelimit/rate_limiter_test.go b/internal/application/lark/ratelimit/rate_limiter_test.go index 6e1518e..1f2f027 100644 --- a/internal/application/lark/ratelimit/rate_limiter_test.go +++ b/internal/application/lark/ratelimit/rate_limiter_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + appconfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/config" + "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/intent" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/config" "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/otel" "github.com/BetaGoRobot/BetaGo-Redefine/pkg/logs" @@ -238,20 +240,146 @@ func TestTriggerTypeWeights(t *testing.T) { limiter := NewSmartRateLimiter(config, rdb) chatID := "test_trigger_weights" - // 测试不同触发类型都允许 - testCases := []TriggerType{ - TriggerTypeIntent, - TriggerTypeRandom, - TriggerTypeReaction, - TriggerTypeRepeat, + if limiter.hardTriggerWeights[TriggerTypeMention] != 0 { + t.Fatalf("mention hard weight = %v, want 0", limiter.hardTriggerWeights[TriggerTypeMention]) + } + if limiter.softTriggerWeights[TriggerTypeMention] <= 0 { + t.Fatalf("mention soft weight = %v, want > 0", limiter.softTriggerWeights[TriggerTypeMention]) + } + if limiter.hardTriggerWeights[TriggerTypeIntent] <= limiter.hardTriggerWeights[TriggerTypeRandom] { + t.Fatalf("passive reply hard weight = %v, active interject hard weight = %v; passive should be heavier", limiter.hardTriggerWeights[TriggerTypeIntent], limiter.hardTriggerWeights[TriggerTypeRandom]) } - for _, tt := range testCases { - allowed, reason := limiter.Allow(ctx, chatID, tt) - if !allowed { - t.Errorf("触发类型 %s 应该允许: %s", tt, reason) - } - limiter.Record(ctx, chatID, tt) - time.Sleep(50 * time.Millisecond) + allowed, reason := limiter.Allow(ctx, chatID, TriggerTypeIntent) + if !allowed { + t.Fatalf("passive reply should be allowed initially: %s", reason) + } + limiter.Record(ctx, chatID, TriggerTypeIntent) + time.Sleep(50 * time.Millisecond) + + allowed, reason = limiter.Allow(ctx, chatID, TriggerTypeRandom) + if !allowed { + t.Fatalf("active interject should be allowed initially: %s", reason) + } +} + +func TestMentionContributesSoftLoadButNotHardBudget(t *testing.T) { + s, rdb := setupTestRedis(t) + defer s.Close() + + ctx := context.Background() + config := &Config{ + MaxMessagesPerHour: 1, + MaxMessagesPerDay: 100, + MinIntervalSeconds: 0.0, + CooldownBaseSeconds: 1.0, + MaxCooldownSeconds: 10.0, + BurstThreshold: 100, + } + + limiter := NewSmartRateLimiter(config, rdb) + chatID := "test_chat_soft_load" + + limiter.Record(ctx, chatID, TriggerTypeMention) + allowed, reason := limiter.Allow(ctx, chatID, TriggerTypeIntent) + if !allowed { + t.Fatalf("single mention should not consume hard passive budget: %s", reason) + } + + for i := 0; i < 3; i++ { + limiter.Record(ctx, chatID, TriggerTypeMention) + } + allowed, reason = limiter.Allow(ctx, chatID, TriggerTypeIntent) + if allowed { + t.Fatal("expected repeated mention traffic to eventually trigger soft-load blocking") + } + if reason == "" { + t.Fatal("expected soft-load block reason") + } +} + +func TestDeciderUsesConfigDrivenThresholdAndReplyMode(t *testing.T) { + s, rdb := setupTestRedis(t) + defer s.Close() + + ctx := context.Background() + limiter := NewSmartRateLimiter(&Config{ + MaxMessagesPerHour: 100, + MaxMessagesPerDay: 100, + MinIntervalSeconds: 0.0, + CooldownBaseSeconds: 1.0, + MaxCooldownSeconds: 10.0, + BurstThreshold: 100, + }, rdb) + decider := NewDecider(limiter) + decider.intentReplyThreshold = func(context.Context, string, string) int { return 90 } + decider.intentFallbackRate = func(context.Context, string, string) int { return 0 } + decider.randomFloat64 = func() float64 { return 0.99 } + + analysis := &intent.IntentAnalysis{ + IntentType: intent.IntentTypeChat, + NeedReply: true, + ReplyConfidence: 72, + ReplyMode: intent.ReplyModePassiveReply, + UserWillingness: 62, + InterruptRisk: 38, + } + decision := decider.DecideIntentReply(ctx, "oc_chat", "ou_actor", analysis) + if decision.Allowed { + t.Fatalf("expected threshold-driven rejection, got allowed with reason: %s", decision.Reason) + } + + decider.intentReplyThreshold = func(context.Context, string, string) int { return 60 } + decision = decider.DecideIntentReply(ctx, "oc_chat", "ou_actor", analysis) + if !decision.Allowed { + t.Fatalf("expected lower configured threshold to allow reply, got: %s", decision.Reason) + } + if decision.TriggerType != TriggerTypeIntent { + t.Fatalf("TriggerType = %q, want %q", decision.TriggerType, TriggerTypeIntent) + } + + decision = decider.DecideIntentReply(ctx, "oc_chat", "ou_actor", &intent.IntentAnalysis{ + NeedReply: true, + ReplyMode: intent.ReplyModeActiveInterject, + ReplyConfidence: 95, + UserWillingness: 90, + InterruptRisk: 10, + }) + if !decision.Allowed { + t.Fatalf("expected high-confidence active interject to pass, got: %s", decision.Reason) + } + if decision.TriggerType != TriggerTypeRandom { + t.Fatalf("active interject trigger = %q, want %q", decision.TriggerType, TriggerTypeRandom) + } +} + +func TestDeciderDirectReplyBypassesPassiveRateLimit(t *testing.T) { + s, rdb := setupTestRedis(t) + defer s.Close() + + ctx := context.Background() + limiter := NewSmartRateLimiter(&Config{ + MaxMessagesPerHour: 1, + MaxMessagesPerDay: 1, + MinIntervalSeconds: 0.0, + CooldownBaseSeconds: 1.0, + MaxCooldownSeconds: 10.0, + BurstThreshold: 100, + }, rdb) + limiter.Record(ctx, "oc_chat", TriggerTypeIntent) + + decider := NewDecider(limiter) + decider.intentReplyThreshold = appconfig.GetIntentReplyThreshold + decider.intentFallbackRate = appconfig.GetIntentFallbackRate + decision := decider.DecideIntentReply(ctx, "oc_chat", "ou_actor", &intent.IntentAnalysis{ + NeedReply: true, + ReplyMode: intent.ReplyModeDirect, + ReplyConfidence: 100, + }) + if !decision.Allowed { + t.Fatalf("direct reply should bypass passive rate limit: %s", decision.Reason) + } + if decision.TriggerType != TriggerTypeMention { + t.Fatalf("TriggerType = %q, want %q", decision.TriggerType, TriggerTypeMention) } } diff --git a/internal/infrastructure/ark_dal/responses.go b/internal/infrastructure/ark_dal/responses.go index 1693d75..cb485d0 100644 --- a/internal/infrastructure/ark_dal/responses.go +++ b/internal/infrastructure/ark_dal/responses.go @@ -467,11 +467,6 @@ func (r *ResponsesImpl[T]) Do(ctx context.Context, sysPrompt, userPrompt string, Input: input, Store: gptr.Of(true), Tools: r.tools, - // Text: &responses.ResponsesText{ - // Format: &responses.TextFormat{ - // Type: responses.TextType_json_object, - // }, - // }, Reasoning: &responses.ResponsesReasoning{ Effort: responses.ReasoningEffort_low, }, diff --git a/internal/infrastructure/lark_dal/larkmsg/record.go b/internal/infrastructure/lark_dal/larkmsg/record.go index f6cf035..cd7dd48 100644 --- a/internal/infrastructure/lark_dal/larkmsg/record.go +++ b/internal/infrastructure/lark_dal/larkmsg/record.go @@ -30,6 +30,14 @@ import ( "go.uber.org/zap" ) +func resolveRecordedBotIdentity(senderID string) (openID, userName string) { + openID = strings.TrimSpace(senderID) + if openID == "" && config.Get() != nil && config.Get().LarkConfig != nil { + openID = strings.TrimSpace(config.Get().LarkConfig.BotOpenID) + } + return openID, "你" +} + func RecordReplyMessage2Opensearch(ctx context.Context, resp *larkim.ReplyMessageResp, contents ...string) { ctx, span := otel.Start(ctx) defer span.End() @@ -79,6 +87,7 @@ func RecordReplyMessage2Opensearch(ctx context.Context, resp *larkim.ReplyMessag jieba := gojieba.NewJieba() defer jieba.Free() ws := jieba.Cut(content, true) + recordedOpenID, recordedUserName := resolveRecordedBotIdentity(utils.AddrOrNil(resp.Data.Sender.Id)) err = opensearch.InsertData(ctx, config.Get().OpensearchConfig.LarkMsgIndex, utils.AddrOrNil(resp.Data.MessageId), &xmodel.MessageIndex{ @@ -89,8 +98,8 @@ func RecordReplyMessage2Opensearch(ctx context.Context, resp *larkim.ReplyMessag CreateTime: utils.Epo2DateZoneMil(utils.MustInt(*resp.Data.CreateTime), time.UTC, time.DateTime), CreateTimeV2: utils.Epo2DateZoneMil(utils.MustInt(*resp.Data.CreateTime), utils.UTC8Loc(), time.RFC3339), Message: embedded, - OpenID: "你", - UserName: "你", + OpenID: recordedOpenID, + UserName: recordedUserName, TokenUsage: usage, }, ) @@ -166,6 +175,7 @@ func RecordMessage2Opensearch(ctx context.Context, resp *larkim.CreateMessageRes jieba := gojieba.NewJieba() defer jieba.Free() ws := jieba.Cut(content, true) + recordedOpenID, recordedUserName := resolveRecordedBotIdentity(utils.AddrOrNil(resp.Data.Sender.Id)) err = opensearch.InsertData(ctx, config.Get().OpensearchConfig.LarkMsgIndex, utils.AddrOrNil(resp.Data.MessageId), @@ -177,8 +187,8 @@ func RecordMessage2Opensearch(ctx context.Context, resp *larkim.CreateMessageRes CreateTime: utils.Epo2DateZoneMil(utils.MustInt(*resp.Data.CreateTime), time.UTC, time.DateTime), CreateTimeV2: utils.Epo2DateZoneMil(utils.MustInt(*resp.Data.CreateTime), utils.UTC8Loc(), time.RFC3339), Message: embedded, - OpenID: "你", - UserName: "你", + OpenID: recordedOpenID, + UserName: recordedUserName, TokenUsage: usage, }, ) diff --git a/internal/infrastructure/lark_dal/larkmsg/record_test.go b/internal/infrastructure/lark_dal/larkmsg/record_test.go new file mode 100644 index 0000000..cf27f28 --- /dev/null +++ b/internal/infrastructure/lark_dal/larkmsg/record_test.go @@ -0,0 +1,42 @@ +package larkmsg + +import ( + "path/filepath" + "testing" + + infraConfig "github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/config" +) + +func useLarkMsgConfigPath(t *testing.T) { + t.Helper() + configPath, err := filepath.Abs("../../../../.dev/config.toml") + if err != nil { + t.Fatalf("resolve config path: %v", err) + } + t.Setenv("BETAGO_CONFIG_PATH", configPath) +} + +func TestResolveRecordedBotIdentityPreservesSenderOpenID(t *testing.T) { + useLarkMsgConfigPath(t) + + openID, userName := resolveRecordedBotIdentity("ou_custom_bot") + if openID != "ou_custom_bot" { + t.Fatalf("openID = %q, want %q", openID, "ou_custom_bot") + } + if userName != "你" { + t.Fatalf("userName = %q, want %q", userName, "你") + } +} + +func TestResolveRecordedBotIdentityFallsBackToConfiguredBotOpenID(t *testing.T) { + useLarkMsgConfigPath(t) + + openID, userName := resolveRecordedBotIdentity("") + want := infraConfig.Get().LarkConfig.BotOpenID + if openID != want { + t.Fatalf("openID = %q, want %q", openID, want) + } + if userName != "你" { + t.Fatalf("userName = %q, want %q", userName, "你") + } +} diff --git a/internal/infrastructure/lark_dal/larkmsg/streaming_agentic.go b/internal/infrastructure/lark_dal/larkmsg/streaming_agentic.go index 766139c..7121541 100644 --- a/internal/infrastructure/lark_dal/larkmsg/streaming_agentic.go +++ b/internal/infrastructure/lark_dal/larkmsg/streaming_agentic.go @@ -73,7 +73,7 @@ func newAgentStreamingCard(opts AgentStreamingCardOptions) RawCard { Divider(), agentStreamingMarkdown(agentReplyPlaceholder, agentReplyElementID), ) - return NewCardV2("BetaGo", elements, CardV2Options{ + return NewCardV2("Agentic Chat", elements, CardV2Options{ HeaderTemplate: "wathet", VerticalSpacing: "8px", Padding: "12px", diff --git a/pkg/xcommand/typed.go b/pkg/xcommand/typed.go index 1d0325d..6bcddba 100644 --- a/pkg/xcommand/typed.go +++ b/pkg/xcommand/typed.go @@ -31,7 +31,7 @@ type ( Name string Desc string Params *arktools.Param - Result ToolResultEncoder + Result ToolResultEncoder `json:"-"` } )