From 0b7d1b999c78c724a8c271a5491ee16d557c5500 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Fri, 13 Mar 2026 18:17:57 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.5.4.dev.260313=20feat(agent):=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=9A=8F=E5=8F=A3=E8=AE=B0=E4=B8=BA=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=8E=A7=E5=88=B6=E7=A0=81=E5=88=86=E6=B5=81=20+=20?= =?UTF-8?q?=E5=8D=95=E8=AF=B7=E6=B1=82=E8=81=9A=E5=90=88=E8=A7=84=E5=88=92?= =?UTF-8?q?=EF=BC=8C=E5=85=B3=E9=97=AD=E9=9D=9E=E6=B5=81=E5=BC=8Fthinking?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=81=87=E6=88=90=E5=8A=9F=EF=BC=8C?= =?UTF-8?q?=E5=B0=86=E9=9A=8F=E5=8F=A3=E8=AE=B0=E5=85=A8=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=BB=8E10s+=E7=BC=A9=E7=9F=AD=E5=88=B05s=E5=B7=A6=E5=8F=B3?= =?UTF-8?q?=EF=BC=8C=E6=98=BE=E8=91=97=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 路由层改为“模型控制码协议”分流(quick_note|chat),替换关键词/置信度猜测 路由命中 quick_note 时信任路由,graph 跳过二次意图判定(减少一次 LLM 调用) 新增单请求聚合规划:一次返回 title/deadline_at/priority_group/priority_reason/banter 快路径优先复用聚合结果;优先级缺失时本地兜底,避免再次触发优先级模型调用 最终回复优先使用聚合 banter,聚合路径缺失时使用固定文案,不再额外润色调用 非流式 Generate 全面显式关闭 thinking,并收紧 max_tokens/temperature(路由、JSON规划、banter) 保留并强化写库成功门槛:task_id > 0 才允许成功回包,修复“回复成功但未落库”风险 增加/更新测试:控制码解析、nonce 校验、标题提取、banter 复用与无效 task_id 防假成功 保持 OpenAI 兼容 SSE 格式与现有流式聊天链路不变 --- backend/agent/prompt.go | 3 +- backend/agent/quick_note_graph.go | 221 +++++++++++++++- backend/agent/quick_note_graph_test.go | 36 +++ backend/agent/quick_note_prompt.go | 36 +++ backend/agent/state.go | 6 + backend/service/agent.go | 89 +++---- backend/service/agent_quick_note.go | 241 ++++++++++++++++-- .../service/agent_quick_note_route_test.go | 72 ++++++ 8 files changed, 629 insertions(+), 75 deletions(-) create mode 100644 backend/agent/quick_note_graph_test.go create mode 100644 backend/service/agent_quick_note_route_test.go diff --git a/backend/agent/prompt.go b/backend/agent/prompt.go index ca348d5..2eb0f26 100644 --- a/backend/agent/prompt.go +++ b/backend/agent/prompt.go @@ -3,7 +3,8 @@ package agent const ( // SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性 SystemPrompt = `你叫 SmartFlow,是专为重邮(CQUPT)学子打造的智能排程专家。 -你的回复应当专业、干练,偶尔可以带一点程序员式的冷幽默。` +你的回复应当专业、干练,偶尔可以带一点程序员式的冷幽默。 +重要约束:你无法直接写入数据库。除非系统明确告知“任务已落库成功”,否则禁止使用“已安排/已记录/已帮你记下”等完成态表述。` // SmartAssistantPrompt 合并了分诊与对话能力的超级提示词 SmartAssistantPrompt = `你叫 SmartFlow,是专为重邮(CQUPT)学子打造的智能排程专家。 diff --git a/backend/agent/quick_note_graph.go b/backend/agent/quick_note_graph.go index 521f3b2..b6075f4 100644 --- a/backend/agent/quick_note_graph.go +++ b/backend/agent/quick_note_graph.go @@ -9,9 +9,11 @@ import ( "time" "github.com/cloudwego/eino-ext/components/model/ark" + einoModel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/schema" + arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" ) const ( @@ -33,6 +35,18 @@ type quickNotePriorityModelOutput struct { Reason string `json:"reason"` } +// quickNotePlanModelOutput 是“单请求聚合规划”节点的模型输出。 +// 说明: +// - 路由命中 quick_note 时,尽量通过这一份结果覆盖“时间/优先级/润色”三步; +// - 任一字段异常不应阻断主链路,后续会有本地兜底与校验。 +type quickNotePlanModelOutput struct { + Title string `json:"title"` + DeadlineAt string `json:"deadline_at"` + PriorityGroup int `json:"priority_group"` + PriorityReason string `json:"priority_reason"` + Banter string `json:"banter"` +} + // QuickNoteGraphRunInput 是运行“随口记 graph”所需的输入依赖。 // 说明: // - EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块); @@ -42,6 +56,12 @@ type QuickNoteGraphRunInput struct { State *QuickNoteState Deps QuickNoteToolDeps + // SkipIntentVerification=true 时,跳过“意图识别二次模型判定”: + // - 适用于上游路由已明确给出 quick_note 的场景; + // - 可减少一次模型调用,降低首包前等待; + // - 仍保留时间合法性校验与写库成功校验,避免脏数据与假成功。 + SkipIntentVerification bool + EmitStage func(stage, detail string) } @@ -95,6 +115,52 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic return nil, errors.New("quick note graph: nil state in intent node") } + if input.SkipIntentVerification { + emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。") + st.IsQuickNoteIntent = true + st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定" + st.PlannedBySingleCall = true + + emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。") + plan, planErr := planQuickNoteInSingleCall(ctx, input.Model, st.RequestNowText, st.RequestNow, st.UserInput) + if planErr != nil { + st.IntentJudgeReason += ";聚合规划失败,回退本地兜底" + } else { + if strings.TrimSpace(plan.Title) != "" { + st.ExtractedTitle = strings.TrimSpace(plan.Title) + } + if plan.Deadline != nil { + st.ExtractedDeadline = plan.Deadline + } + st.ExtractedDeadlineText = strings.TrimSpace(plan.DeadlineText) + if IsValidTaskPriority(plan.PriorityGroup) { + st.ExtractedPriority = plan.PriorityGroup + st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason) + } + st.ExtractedBanter = strings.TrimSpace(plan.Banter) + } + + if strings.TrimSpace(st.ExtractedTitle) == "" { + st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput) + } + + emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") + + // 仍以“用户原句”的本地解析与校验作为硬约束,防止模型给出非法时间。 + userDeadline, userHasTimeHint, userDeadlineErr := parseOptionalDeadlineFromUserInput(st.UserInput, st.RequestNow) + if userHasTimeHint && userDeadlineErr != nil { + st.DeadlineValidationError = userDeadlineErr.Error() + st.AssistantReply = "我识别到你给了时间信息,但这个时间格式我没法准确解析,请改成例如:2026-03-20 18:30、明天下午3点、下周一上午9点。" + emitStage("quick_note.failed", "时间校验失败,未执行写入。") + return st, nil + } + if userDeadline != nil { + st.ExtractedDeadline = userDeadline + st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) + } + return st, nil + } + emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。") prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s @@ -179,6 +245,21 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic return st, nil } + // 命中“单请求聚合规划”时,优先复用其优先级结果,避免重复模型调用。 + if IsValidTaskPriority(st.ExtractedPriority) { + if strings.TrimSpace(st.ExtractedPriorityReason) == "" { + st.ExtractedPriorityReason = "复用聚合规划优先级" + } + emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。") + return st, nil + } + if input.SkipIntentVerification || st.PlannedBySingleCall { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底" + emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。") + return st, nil + } + emitStage("quick_note.priority.evaluating", "正在评估任务优先级。") deadlineText := "无" @@ -290,6 +371,20 @@ func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*Quic return st, nil } + // 成功判定加硬门槛:必须拿到有效 task_id。 + // 目的: + // 1) 防止工具返回结构异常时被误判为“写入成功”; + // 2) 避免出现“回复已安排,但数据库实际没记录”的错误体验; + // 3) 命中该分支时会走既有重试策略,重试耗尽后明确报错给用户。 + if toolOutput.TaskID <= 0 { + st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID)) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。" + emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。") + } + return st, nil + } + st.RecordToolSuccess(toolOutput.TaskID) if strings.TrimSpace(toolOutput.Title) != "" { st.ExtractedTitle = strings.TrimSpace(toolOutput.Title) @@ -397,11 +492,23 @@ func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.Invo } func callModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string) (string, error) { + return callModelForJSONWithMaxTokens(ctx, chatModel, systemPrompt, userPrompt, 256) +} + +func callModelForJSONWithMaxTokens(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) { messages := []*schema.Message{ schema.SystemMessage(systemPrompt), schema.UserMessage(userPrompt), } - resp, err := chatModel.Generate(ctx, messages) + opts := []einoModel.Option{ + ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}), + einoModel.WithTemperature(0), + } + if maxTokens > 0 { + opts = append(opts, einoModel.WithMaxTokens(maxTokens)) + } + + resp, err := chatModel.Generate(ctx, messages, opts...) if err != nil { return "", err } @@ -415,6 +522,78 @@ func callModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPromp return content, nil } +type quickNotePlannedResult struct { + Title string + Deadline *time.Time + DeadlineText string + PriorityGroup int + PriorityReason string + Banter string +} + +// planQuickNoteInSingleCall 在一次模型调用里完成“时间/优先级/banter”聚合规划。 +// 设计原则: +// 1) 路由已命中 quick_note 时优先走该函数,减少串行模型调用; +// 2) 输出字段解析失败时返回 error,让上层回退到本地/后续节点兜底; +// 3) 对 banter 做轻量清洗,避免多行输出污染最终回复。 +func planQuickNoteInSingleCall( + ctx context.Context, + chatModel *ark.ChatModel, + nowText string, + now time.Time, + userInput string, +) (*quickNotePlannedResult, error) { + prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s +用户输入:%s + +请仅输出 JSON(不要 markdown,不要解释),字段如下: +{ + "title": string, + "deadline_at": string, + "priority_group": 1|2|3|4, + "priority_reason": string, + "banter": string +} + +约束: +1) deadline_at 只允许 "yyyy-MM-dd HH:mm" 或空字符串; +2) 若用户给了相对时间(如明天/今晚/下周一),必须换算为绝对时间; +3) banter 只允许一句中文,不超过30字,不得改动任务事实。`, + nowText, + strings.TrimSpace(userInput), + ) + + raw, err := callModelForJSONWithMaxTokens(ctx, chatModel, QuickNotePlanPrompt, prompt, 220) + if err != nil { + return nil, err + } + parsed, parseErr := parseJSONPayload[quickNotePlanModelOutput](raw) + if parseErr != nil { + return nil, parseErr + } + + result := &quickNotePlannedResult{ + Title: strings.TrimSpace(parsed.Title), + DeadlineText: strings.TrimSpace(parsed.DeadlineAt), + PriorityGroup: parsed.PriorityGroup, + PriorityReason: strings.TrimSpace(parsed.PriorityReason), + Banter: strings.TrimSpace(parsed.Banter), + } + + if result.Banter != "" { + if idx := strings.Index(result.Banter, "\n"); idx >= 0 { + result.Banter = strings.TrimSpace(result.Banter[:idx]) + } + } + + if result.DeadlineText != "" { + if deadline, deadlineErr := parseOptionalDeadlineWithNow(result.DeadlineText, now); deadlineErr == nil { + result.Deadline = deadline + } + } + return result, nil +} + func parseJSONPayload[T any](raw string) (*T, error) { clean := strings.TrimSpace(raw) if clean == "" { @@ -464,3 +643,43 @@ func fallbackPriority(st *QuickNoteState) int { } return QuickNotePrioritySimpleNotImportant } + +// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。 +// 设计原则: +// 1) 不依赖模型,避免再引入一次额外 LLM 调用; +// 2) 优先保守提取,宁可稍长,也不要误删关键信息; +// 3) 只做轻量清洗,不做复杂语义改写,保持可预期。 +func deriveQuickNoteTitleFromInput(userInput string) string { + text := strings.TrimSpace(userInput) + if text == "" { + return "这条任务" + } + + // 去掉常见前缀口头语,保留核心任务描述。 + prefixes := []string{ + "请帮我", "麻烦帮我", "麻烦你", "帮我", "提醒我", "请提醒我", "记一下", "记个", "帮我记一下", + } + for _, prefix := range prefixes { + if strings.HasPrefix(text, prefix) { + text = strings.TrimSpace(strings.TrimPrefix(text, prefix)) + break + } + } + + // 去掉常见尾部提醒口头语,避免把“记得喊我/q我”也写入标题。 + suffixSeparators := []string{ + ",记得", ",记得", ",到时候", ",到时候", " 到时候", ",别忘了", ",别忘了", "。记得", + } + for _, sep := range suffixSeparators { + if idx := strings.Index(text, sep); idx > 0 { + text = strings.TrimSpace(text[:idx]) + break + } + } + + text = strings.Trim(text, ",,。.!!?;; ") + if text == "" { + return strings.TrimSpace(userInput) + } + return text +} diff --git a/backend/agent/quick_note_graph_test.go b/backend/agent/quick_note_graph_test.go new file mode 100644 index 0000000..7435f97 --- /dev/null +++ b/backend/agent/quick_note_graph_test.go @@ -0,0 +1,36 @@ +package agent + +import "testing" + +func TestDeriveQuickNoteTitleFromInput(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + { + name: "保留核心事项并去掉尾部提醒口头语", + input: "明天上午12点我要去取快递,到时候记得q我", + want: "明天上午12点我要去取快递", + }, + { + name: "去掉常见前缀口头语", + input: "提醒我周五下午三点交实验报告", + want: "周五下午三点交实验报告", + }, + { + name: "空输入兜底", + input: " ", + want: "这条任务", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := deriveQuickNoteTitleFromInput(tc.input) + if got != tc.want { + t.Fatalf("title 提取不符合预期,got=%q want=%q", got, tc.want) + } + }) + } +} diff --git a/backend/agent/quick_note_prompt.go b/backend/agent/quick_note_prompt.go index f09a958..1bddd8d 100644 --- a/backend/agent/quick_note_prompt.go +++ b/backend/agent/quick_note_prompt.go @@ -1,6 +1,42 @@ package agent const ( + // QuickNoteRouteControlPrompt 用于“首段控制码分流”: + // - 仅负责判断用户输入应走 quick_note 还是 chat; + // - 不直接回答用户问题; + // - 必须输出可机读控制码,便于后端无歧义解析。 + QuickNoteRouteControlPrompt = `你是 SmartFlow 的请求分流控制器。 +你的唯一任务是给后端返回可机读控制码,不要做用户可见回复,不要解释。 + +判定规则: +1) 若用户表达“希望你在将来提醒/记录/安排某件事”,输出 quick_note。 +2) 其余情况输出 chat(包括闲聊、知识问答、纯讨论、观点交流)。 +3) 口语变体(如“d我/q我/戳我/到点喊我/记得提醒我”)也属于 quick_note。 + +输出格式必须严格如下(两行,大小写不敏感): + +一句不超过30字的中文理由 + + 禁止输出任何其他内容。` + + // QuickNotePlanPrompt 用于“单请求聚合规划”: + // - 在一次调用内完成标题抽取、时间归一化、优先级评估、跟进句生成; + // - 主要用于路由已明确命中 quick_note 的场景,以降低串行 LLM 调用次数。 + QuickNotePlanPrompt = `你是 SmartFlow 的任务聚合规划器。 +你将基于用户输入,一次性输出任务规划结果,供后端直接写库。 + +必须完成以下四件事: +1) 提取任务标题 title(简洁明确)。 +2) 归一化截止时间 deadline_at(若存在时间线索,必须输出绝对时间)。 +3) 评估优先级 priority_group(1~4)。 +4) 生成一句轻松跟进句 banter(不超过30字)。 + +输出要求: +- 仅输出 JSON,不要 markdown,不要解释。 +- deadline_at 仅允许 "yyyy-MM-dd HH:mm" 或空字符串。 +- priority_group 仅允许 1|2|3|4。 +- banter 不得新增或修改任务事实(任务名、时间、优先级)。` + // QuickNoteIntentPrompt 用于第一阶段:判断用户输入是否属于“随口记”。 // 设计约束: // 1) 只做识别与抽取,不允许模型宣称“已写库”; diff --git a/backend/agent/state.go b/backend/agent/state.go index d67dc88..bf7b465 100644 --- a/backend/agent/state.go +++ b/backend/agent/state.go @@ -76,6 +76,12 @@ type QuickNoteState struct { ExtractedDeadline *time.Time ExtractedDeadlineText string ExtractedPriority int + // ExtractedBanter 是聚合规划阶段生成的“轻松跟进句”。 + // 该字段非空时,最终回复阶段可直接复用,避免再触发一次独立润色模型调用。 + ExtractedBanter string + // PlannedBySingleCall 标记本次是否走了“单请求聚合规划”快路径。 + // 用于在后续节点做更激进的性能策略(例如缺失字段时直接本地兜底,避免再触发模型调用)。 + PlannedBySingleCall bool // ExtractedPriorityReason 记录优先级评估理由,便于后续排查模型判断是否符合预期。 ExtractedPriorityReason string diff --git a/backend/service/agent.go b/backend/service/agent.go index 35148dd..84ab1a6 100644 --- a/backend/service/agent.go +++ b/backend/service/agent.go @@ -197,62 +197,38 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin } } - // 3) 如果命中“任务安排关键词”,开启随口记阶段推送(伪装成 reasoning chunk)。 - if shouldEmitQuickNoteProgress(userMessage) { - go func() { - defer close(outChan) + // 3) 统一异步分流: + // - 先走“模型控制码路由”决定 quick_note / chat; + // - 路由命中 quick_note 时推阶段状态并执行 graph; + // - 路由命中 chat 时直接普通流式聊天。 + go func() { + defer close(outChan) - progress := newQuickNoteProgressEmitter(outChan, resolvedModelName, true) - progress.Emit("request.accepted", "检测到任务安排请求,开始执行随口记流程。") - - quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph( - ctx, - selectedModel, - userMessage, - userID, - chatID, - traceID, - progress.Emit, - ) - if quickErr != nil { - log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr) - } - - if quickHandled { - progress.Emit("quick_note.reply.polishing", "正在结合你的话题润色回复。") - quickReply := buildQuickNoteFinalReply(ctx, selectedModel, userMessage, quickState) - if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, quickReply); emitErr != nil { - pushErrNonBlocking(errChan, emitErr) - return - } - - s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan) - return - } - - progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。") + routing := s.decideQuickNoteRouting(ctx, selectedModel, userMessage) + if !routing.EnterQuickNote { s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) - }() - return outChan, errChan - } + return + } - // 4) 无阶段推送模式:保持原逻辑,先尝试随口记,不命中再走普通聊天。 - quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph( - ctx, - selectedModel, - userMessage, - userID, - chatID, - traceID, - nil, - ) - if quickErr != nil { - log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr) - } - if quickHandled { - go func() { - defer close(outChan) + progress := newQuickNoteProgressEmitter(outChan, resolvedModelName, true) + progress.Emit("request.accepted", routing.Detail) + quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph( + ctx, + selectedModel, + userMessage, + userID, + chatID, + traceID, + routing.TrustRoute, + progress.Emit, + ) + if quickErr != nil { + log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr) + } + + if quickHandled { + progress.Emit("quick_note.reply.polishing", "正在结合你的话题润色回复。") quickReply := buildQuickNoteFinalReply(ctx, selectedModel, userMessage, quickState) if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, quickReply); emitErr != nil { pushErrNonBlocking(errChan, emitErr) @@ -260,13 +236,10 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin } s.persistChatAfterReply(ctx, userID, chatID, userMessage, quickReply, errChan) - }() - return outChan, errChan - } + return + } - // 5) 普通流式聊天。 - go func() { - defer close(outChan) + progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。") s.runNormalChatFlow(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) }() diff --git a/backend/service/agent_quick_note.go b/backend/service/agent_quick_note.go index 921cf8a..b07d018 100644 --- a/backend/service/agent_quick_note.go +++ b/backend/service/agent_quick_note.go @@ -4,16 +4,65 @@ import ( "context" "fmt" "log" + "regexp" "strings" "time" "github.com/LoveLosita/smartflow/backend/agent" "github.com/LoveLosita/smartflow/backend/model" "github.com/cloudwego/eino-ext/components/model/ark" + einoModel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" "github.com/google/uuid" + arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" ) +const ( + // quickNoteRouteControlTimeout 是“模型控制码分流”这一步的额外超时。 + // 说明: + // 1) 设为 0 代表“不额外加子超时”,完全跟随父请求上下文; + // 2) 避免路由步骤因过短子超时反复触发 context deadline exceeded; + // 3) 若后续需要强制保护,可再改为 >0 的值并通过配置化管理。 + quickNoteRouteControlTimeout = 0 * time.Second +) + +var ( + // quickNoteRouteHeaderRegex 解析模型返回的控制头: + // + quickNoteRouteHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note|chat)["']?[^>]*>`) + // quickNoteRouteReasonRegex 解析可选理由块: + // ... + quickNoteRouteReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`) +) + +type quickNoteRouteAction string + +const ( + quickNoteRouteActionChat quickNoteRouteAction = "chat" + quickNoteRouteActionQuickNote quickNoteRouteAction = "quick_note" +) + +// quickNoteRouteControlDecision 是“模型控制码分流”的结构化结果。 +// 该结构不会直接暴露给前端,仅用于服务端决定后续链路: +// - action=quick_note -> 进入随口记 graph; +// - action=chat -> 进入普通聊天流。 +type quickNoteRouteControlDecision struct { + Action quickNoteRouteAction + Reason string + Raw string +} + +// quickNoteRoutingDecision 是对“是否进入随口记 graph”的最终决策。 +// 字段说明: +// - EnterQuickNote:是否进入随口记 graph; +// - TrustRoute:是否信任上游控制码并跳过 graph 内的二次意图判定; +// - Detail:阶段状态文案,用于前端/调试可观测性。 +type quickNoteRoutingDecision struct { + EnterQuickNote bool + TrustRoute bool + Detail string +} + // quickNoteProgressEmitter 负责把“链路阶段状态”伪装成 OpenAI 兼容的 reasoning_content chunk。 // 设计目标: // 1) 不改现有 OpenAI 兼容协议外壳; @@ -75,6 +124,8 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) { // - handled=true:本次请求已在随口记链路处理完成(成功/失败都会返回文案); // - handled=false:不是随口记意图,调用方应回落普通聊天链路; // - state:用于拼接最终“一次性正文回复”。 +// 参数说明: +// - trustRoute=true:信任上游控制码,graph 跳过二次意图判定,直接进入时间校验/优先级/写库流程。 func (s *AgentService) tryHandleQuickNoteWithGraph( ctx context.Context, selectedModel *ark.ChatModel, @@ -82,6 +133,7 @@ func (s *AgentService) tryHandleQuickNoteWithGraph( userID int, chatID string, traceID string, + trustRoute bool, emitStage func(stage, detail string), ) (handled bool, state *agent.QuickNoteState, err error) { if s.taskRepo == nil || selectedModel == nil { @@ -116,7 +168,8 @@ func (s *AgentService) tryHandleQuickNoteWithGraph( }, nil }, }, - EmitStage: emitStage, + SkipIntentVerification: trustRoute, + EmitStage: emitStage, }) if runErr != nil { return false, nil, runErr @@ -166,7 +219,8 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, return "我这次没成功记上,别急,再发我一次我马上补上。" } - if state.Persisted { + // 仅当“确实拿到了有效 task_id”时才走成功文案,避免出现“回复成功但库里没数据”的错觉。 + if state.Persisted && state.PersistedTaskID > 0 { title := strings.TrimSpace(state.ExtractedTitle) if title == "" { title = "这条任务" @@ -184,6 +238,16 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, factLine := fmt.Sprintf("好,给你安排上了:%s(%s%s)。", title, priorityText, deadlineText) + // 优先复用“聚合规划阶段”产出的跟进句,避免再触发一次润色模型调用。 + if strings.TrimSpace(state.ExtractedBanter) != "" { + return factLine + " " + strings.TrimSpace(state.ExtractedBanter) + } + if state.PlannedBySingleCall { + // 快路径兜底:单请求聚合已走过一次模型调用,若未产出 banter 则直接使用固定文案, + // 避免再发起额外模型请求拉高总时延。 + return factLine + " 已帮你稳稳记下,放心推进。" + } + banter, err := generateQuickNoteBanter(ctx, selectedModel, userMessage, title, priorityText, deadlineText) if err != nil { return factLine + " 这下可以先安心推进,不用等 ddl 来敲门了。" @@ -239,7 +303,11 @@ func generateQuickNoteBanter( schema.UserMessage(prompt), } - resp, err := selectedModel.Generate(ctx, messages) + resp, err := selectedModel.Generate(ctx, messages, + ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}), + einoModel.WithTemperature(0.7), + einoModel.WithMaxTokens(72), + ) if err != nil { return "", err } @@ -260,20 +328,163 @@ func generateQuickNoteBanter( return text, nil } -// shouldEmitQuickNoteProgress 用于判断是否应在“等待阶段”推送状态块。 -// 规则偏保守:只要出现明显“记任务/提醒”语义,就开启阶段推送。 -func shouldEmitQuickNoteProgress(userMessage string) bool { - text := strings.TrimSpace(userMessage) - if text == "" { - return false - } - keywords := []string{"记一下", "帮我记", "提醒", "任务", "待办", "日程", "安排", "截止", "ddl"} - for _, kw := range keywords { - if strings.Contains(text, kw) { - return true +// decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。 +// 新策略:改为“模型控制码分流”,不再依赖关键词和本地猜测。 +// +// 处理流程: +// 1) 先调用路由模型拿控制码(quick_note / chat); +// 2) 控制码可解析时按模型判定分流; +// 3) 控制码超时/解析失败时,进入随口记 graph 做兜底意图识别,避免遗漏任务。 +// +// 返回值说明: +// - EnterQuickNote=true:进入随口记 graph; +// - TrustRoute=true:跳过 graph 内二次意图判定; +// - Detail:用于阶段推送,向前端解释“为何进入该分支”。 +func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) quickNoteRoutingDecision { + decision, err := s.routeByModelControlTag(ctx, selectedModel, userMessage) + if err != nil { + if deadline, ok := ctx.Deadline(); ok { + log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline_in_ms=%d route_timeout_ms=%d", + err, + time.Until(deadline).Milliseconds(), + quickNoteRouteControlTimeout.Milliseconds(), + ) + } else { + log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline=none route_timeout_ms=%d", + err, + quickNoteRouteControlTimeout.Milliseconds(), + ) + } + return quickNoteRoutingDecision{ + EnterQuickNote: true, + TrustRoute: false, + Detail: "路由判定暂不可用,已进入任务识别兜底流程。", } } - return false + + switch decision.Action { + case quickNoteRouteActionQuickNote: + reason := strings.TrimSpace(decision.Reason) + if reason == "" { + reason = "模型识别到任务安排请求,准备执行随口记。" + } + return quickNoteRoutingDecision{ + EnterQuickNote: true, + TrustRoute: true, + Detail: reason, + } + case quickNoteRouteActionChat: + return quickNoteRoutingDecision{ + EnterQuickNote: false, + TrustRoute: false, + Detail: "", + } + default: + log.Printf("quick note 未知路由动作,进入 graph 兜底: action=%s raw=%s", decision.Action, decision.Raw) + return quickNoteRoutingDecision{ + EnterQuickNote: true, + TrustRoute: false, + Detail: "路由结果异常,已进入任务识别兜底流程。", + } + } +} + +// routeByModelControlTag 通过模型返回“控制码”完成分流。 +// 输出协议由 QuickNoteRouteControlPrompt 约束,核心字段: +// - nonce:防伪随机串,防止模型回显历史脏内容; +// - action:quick_note / chat。 +func (s *AgentService) routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*quickNoteRouteControlDecision, error) { + if selectedModel == nil { + return nil, fmt.Errorf("model is nil") + } + + nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", "")) + routeCtx, cancel := deriveRouteControlContext(ctx, quickNoteRouteControlTimeout) + defer cancel() + + nowText := time.Now().In(time.Local).Format("2006-01-02 15:04") + userPrompt := fmt.Sprintf("nonce=%s\n当前时间=%s\n用户输入=%s", nonce, nowText, strings.TrimSpace(userMessage)) + resp, err := selectedModel.Generate(routeCtx, []*schema.Message{ + schema.SystemMessage(agent.QuickNoteRouteControlPrompt), + schema.UserMessage(userPrompt), + }, + ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}), + einoModel.WithTemperature(0), + einoModel.WithMaxTokens(80), + ) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("empty route response") + } + + raw := strings.TrimSpace(resp.Content) + if raw == "" { + return nil, fmt.Errorf("empty route content") + } + + decision, parseErr := parseQuickNoteRouteControlTag(raw, nonce) + if parseErr != nil { + return nil, parseErr + } + return decision, nil +} + +// deriveRouteControlContext 为“控制码路由”创建子上下文。 +// 设计要点: +// 1. 如果父 ctx 没有 deadline,则增加一个默认上限,防止异常请求无限等待; +// 2. 如果父 ctx 已有更紧 deadline,则直接沿用父 ctx,不再额外缩短, +// 避免出现“父请求还活着,但子路由因更短超时提前失败”的误判。 +func deriveRouteControlContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return context.WithCancel(parent) + } + if deadline, ok := parent.Deadline(); ok { + if time.Until(deadline) <= timeout { + return context.WithCancel(parent) + } + } + return context.WithTimeout(parent, timeout) +} + +// parseQuickNoteRouteControlTag 解析模型输出控制码。 +// 容错策略: +// - 允许大小写、属性顺序、标签内额外属性有差异; +// - 但 nonce 必须精确匹配,action 必须为 quick_note/chat。 +func parseQuickNoteRouteControlTag(raw, expectedNonce string) (*quickNoteRouteControlDecision, error) { + text := strings.TrimSpace(raw) + if text == "" { + return nil, fmt.Errorf("route content is empty") + } + + header := quickNoteRouteHeaderRegex.FindStringSubmatch(text) + if len(header) < 3 { + return nil, fmt.Errorf("route header not found: %s", text) + } + + nonce := strings.ToLower(strings.TrimSpace(header[1])) + if nonce != strings.ToLower(strings.TrimSpace(expectedNonce)) { + return nil, fmt.Errorf("route nonce mismatch") + } + + actionText := strings.ToLower(strings.TrimSpace(header[2])) + action := quickNoteRouteAction(actionText) + if action != quickNoteRouteActionQuickNote && action != quickNoteRouteActionChat { + return nil, fmt.Errorf("invalid route action: %s", actionText) + } + + reason := "" + reasonMatch := quickNoteRouteReasonRegex.FindStringSubmatch(text) + if len(reasonMatch) >= 2 { + reason = strings.TrimSpace(reasonMatch[1]) + } + + return &quickNoteRouteControlDecision{ + Action: action, + Reason: reason, + Raw: text, + }, nil } // persistChatAfterReply 在“随口记 graph”返回后,复用当前项目的后置持久化策略: diff --git a/backend/service/agent_quick_note_route_test.go b/backend/service/agent_quick_note_route_test.go new file mode 100644 index 0000000..996ba4d --- /dev/null +++ b/backend/service/agent_quick_note_route_test.go @@ -0,0 +1,72 @@ +package service + +import ( + "strings" + "testing" + + "github.com/LoveLosita/smartflow/backend/agent" +) + +// TestParseQuickNoteRouteControlTag_QuickNote +// 目的:验证模型控制码在 action=quick_note 时可被稳定解析, +// 并且会校验 nonce,避免历史脏内容或伪造片段误命中。 +func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) { + nonce := "abc123nonce" + raw := ` +用户明确在请求未来提醒` + + decision, err := parseQuickNoteRouteControlTag(raw, nonce) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + if decision == nil { + t.Fatalf("decision 不应为空") + } + if decision.Action != quickNoteRouteActionQuickNote { + t.Fatalf("action 解析错误,期望=%s 实际=%s", quickNoteRouteActionQuickNote, decision.Action) + } + if strings.TrimSpace(decision.Reason) == "" { + t.Fatalf("reason 不应为空") + } +} + +// TestParseQuickNoteRouteControlTag_NonceMismatch +// 目的:确保 nonce 不匹配时直接报错,避免把非本次请求的控制码当作有效路由。 +func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) { + raw := `` + if _, err := parseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil { + t.Fatalf("期望 nonce 不匹配时报错,但未报错") + } +} + +// TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID +// 目的:即使 state.Persisted 被错误置为 true,只要 task_id 无效,也不能返回“安排成功”文案。 +func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) { + state := &agent.QuickNoteState{ + Persisted: true, + PersistedTaskID: 0, + ExtractedTitle: "去下馆子", + } + + reply := buildQuickNoteFinalReply(nil, nil, "我今天晚上6点要去下馆子,记得喊我", state) + if strings.Contains(reply, "给你安排上了") || strings.Contains(reply, "已安排") { + t.Fatalf("不应返回成功文案,实际回复=%s", reply) + } +} + +// TestBuildQuickNoteFinalReply_UseExtractedBanter +// 目的:当聚合规划阶段已经产出 banter 时,最终回复应直接复用,避免再次调用润色模型。 +func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) { + state := &agent.QuickNoteState{ + Persisted: true, + PersistedTaskID: 12, + ExtractedTitle: "明天去取快递", + ExtractedPriority: 2, + ExtractedBanter: "取件路上注意保暖,别被风吹懵了。", + } + + reply := buildQuickNoteFinalReply(nil, nil, "明天上午12点我要去取快递,到时候记得q我", state) + if !strings.Contains(reply, "取件路上注意保暖") { + t.Fatalf("期望复用 ExtractedBanter,实际回复=%s", reply) + } +}