diff --git a/backend/agent/README.md b/backend/agent/README.md index f6e28a1..440fad3 100644 --- a/backend/agent/README.md +++ b/backend/agent/README.md @@ -1,31 +1,23 @@ -# backend/agent 目录说明 +# backend/agent 目录说明 -该目录当前按“聊天流式输出能力”和“可编排的随口记能力”拆分: +该目录已按“路由 / 聊天 / 随口记”三层拆分,便于阅读、调试与扩展: -1. `graph.go` -- 仅负责现有流式聊天输出封装(SSE/OpenAI 兼容 chunk 转换)。 -- 已有线上链路依赖,当前不改业务逻辑。 +1. `route/` +- `route.go`:只负责模型控制码分流(`quick_note` / `chat`)。 +- 提供控制码解析、nonce 校验、路由兜底,不参与写库与回复拼装。 -2. `prompt.go` -- 通用 Agent 提示词。 +2. `chat/` +- `stream.go`:普通聊天流式输出封装(SSE/OpenAI 兼容 chunk 转换)。 +- `prompt.go`:聊天主系统提示词。 -3. `quick_note_prompt.go` -- AI 随口记专用提示词(意图识别、优先级评估)。 +3. `quicknote/` +- `graph.go`:只负责图编排连线与分支,不承载节点内部实现。 +- `nodes.go`:节点实现(意图识别、优先级评估、持久化、分支选择)。 +- `tool.go`:工具定义、参数校验、deadline 解析、写库工具打包。 +- `state.go`:随口记状态容器与重试状态记录。 +- `prompt.go`:随口记提示词(控制码路由、聚合规划、优先级评估、回复润色)。 -4. `state.go` -- 随口记链路状态结构(意图标记、抽取结果、重试计数、持久化结果)。 +4. `README.md`(当前文件) +- 记录目录职责边界,帮助后续继续按同样范式扩展 `query/update` 等技能链路。 -5. `tool.go` -- 随口记工具打包入口: - - `BuildQuickNoteToolBundle` - - 工具输入输出 schema - - deadline 解析与优先级校验 - -6. `quick_note_graph.go` -- 随口记 graph 编排实现: - - 节点1:意图识别 - - 节点2:优先级评估 - - 节点3:调用写库工具 - - 分支:失败自动重试(最多 3 次) - -> 说明:服务层通过 `RunQuickNoteGraph` 调用该图;若判定为非随口记意图,会自动回落到原有普通流式聊天逻辑。 +> 说明:服务层仍通过 `RunQuickNoteGraph` 调用随口记图;若判定为非随口记意图,会自动回落到普通流式聊天链路。 diff --git a/backend/agent/prompt.go b/backend/agent/chat/prompt.go similarity index 99% rename from backend/agent/prompt.go rename to backend/agent/chat/prompt.go index 2eb0f26..67dce94 100644 --- a/backend/agent/prompt.go +++ b/backend/agent/chat/prompt.go @@ -1,4 +1,4 @@ -package agent +package chat const ( // SystemPrompt 全局系统人设:定义 SmartFlow 的基本调性 diff --git a/backend/agent/graph.go b/backend/agent/chat/stream.go similarity index 99% rename from backend/agent/graph.go rename to backend/agent/chat/stream.go index 245ebd9..4c2093d 100644 --- a/backend/agent/graph.go +++ b/backend/agent/chat/stream.go @@ -1,4 +1,4 @@ -package agent +package chat import ( "context" diff --git a/backend/agent/quick_note_graph.go b/backend/agent/quick_note_graph.go deleted file mode 100644 index b6075f4..0000000 --- a/backend/agent/quick_note_graph.go +++ /dev/null @@ -1,685 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "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 ( - quickNoteGraphNodeIntent = "quick_note_intent" - quickNoteGraphNodeRank = "quick_note_priority" - quickNoteGraphNodePersist = "quick_note_persist" - quickNoteGraphNodeExit = "quick_note_exit" -) - -type quickNoteIntentModelOutput struct { - IsQuickNote bool `json:"is_quick_note"` - Title string `json:"title"` - DeadlineAt string `json:"deadline_at"` - Reason string `json:"reason"` -} - -type quickNotePriorityModelOutput struct { - PriorityGroup int `json:"priority_group"` - 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 状态块); -// - 不传 EmitStage 时,图逻辑保持静默执行。 -type QuickNoteGraphRunInput struct { - Model *ark.ChatModel - State *QuickNoteState - Deps QuickNoteToolDeps - - // SkipIntentVerification=true 时,跳过“意图识别二次模型判定”: - // - 适用于上游路由已明确给出 quick_note 的场景; - // - 可减少一次模型调用,降低首包前等待; - // - 仍保留时间合法性校验与写库成功校验,避免脏数据与假成功。 - SkipIntentVerification bool - - EmitStage func(stage, detail string) -} - -// RunQuickNoteGraph 执行“随口记”图编排。 -// 设计目标: -// 1) 意图识别和信息抽取与写库解耦; -// 2) 发生模型抖动或工具失败时,具备可控降级和重试; -// 3) 时间解析严格可控,避免把非法日期静默写成 NULL。 -func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*QuickNoteState, error) { - if input.Model == nil { - return nil, errors.New("quick note graph: model is nil") - } - if input.State == nil { - return nil, errors.New("quick note graph: state is nil") - } - if err := input.Deps.validate(); err != nil { - return nil, err - } - - emitStage := func(stage, detail string) { - if input.EmitStage != nil { - input.EmitStage(stage, detail) - } - } - - // 统一初始化“当前时间基准”: - // - RequestNow 用于相对时间解析; - // - RequestNowText 用于拼接到提示词,让模型知道“现在是几点”。 - if input.State.RequestNow.IsZero() { - input.State.RequestNow = quickNoteNowToMinute() - } - if strings.TrimSpace(input.State.RequestNowText) == "" { - input.State.RequestNowText = formatQuickNoteTimeToMinute(input.State.RequestNow) - } - - toolBundle, err := BuildQuickNoteToolBundle(ctx, input.Deps) - if err != nil { - return nil, err - } - createTaskTool, err := getInvokableToolByName(toolBundle, ToolNameQuickNoteCreateTask) - if err != nil { - return nil, err - } - - graph := compose.NewGraph[*QuickNoteState, *QuickNoteState]() - - // 节点1:意图识别与信息抽取。 - if err = graph.AddLambdaNode(quickNoteGraphNodeIntent, compose.InvokableLambda( - func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { - if st == nil { - 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 -用户输入:%s -请仅输出 JSON(不要 markdown,不要解释),字段如下: -{ - "is_quick_note": boolean, - "title": string, - "deadline_at": string, - "reason": string -} -字段约束: -1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。 -2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。 -3) 如果用户没有提及时间,deadline_at 输出空字符串。`, - st.RequestNowText, - st.UserInput, - ) - raw, callErr := callModelForJSON(ctx, input.Model, QuickNoteIntentPrompt, prompt) - if callErr != nil { - st.IsQuickNoteIntent = false - st.IntentJudgeReason = "意图识别失败,回退普通聊天" - return st, nil - } - - parsed, parseErr := parseJSONPayload[quickNoteIntentModelOutput](raw) - if parseErr != nil { - st.IsQuickNoteIntent = false - st.IntentJudgeReason = "意图识别结果不可解析,回退普通聊天" - return st, nil - } - - st.IsQuickNoteIntent = parsed.IsQuickNote - st.IntentJudgeReason = strings.TrimSpace(parsed.Reason) - if !st.IsQuickNoteIntent { - return st, nil - } - - title := strings.TrimSpace(parsed.Title) - if title == "" { - title = strings.TrimSpace(st.UserInput) - } - st.ExtractedTitle = title - - emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") - - // Step A:优先尝试解析模型抽取出来的 deadline。 - st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt) - if st.ExtractedDeadlineText != "" { - if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil { - st.ExtractedDeadline = deadline - } - } - - // Step B:基于用户原句执行“本地时间解析 + 合法性校验”。 - 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 st.ExtractedDeadline == nil && userDeadline != nil { - st.ExtractedDeadline = userDeadline - if st.ExtractedDeadlineText == "" { - st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) - } - } - return st, nil - })); err != nil { - return nil, err - } - - // 节点2:优先级评估。 - if err = graph.AddLambdaNode(quickNoteGraphNodeRank, compose.InvokableLambda( - func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in priority node") - } - if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { - 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 := "无" - if st.ExtractedDeadline != nil { - deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline) - } - deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText) - if deadlineClue == "" { - deadlineClue = "无" - } - - prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s -请对以下任务评估优先级: -- 任务标题:%s -- 用户原始输入:%s -- 时间线索原文:%s -- 归一化截止时间:%s - -请仅输出 JSON(不要 markdown,不要解释): -{ - "priority_group": 1|2|3|4, - "reason": "简短理由" -}`, - st.RequestNowText, - st.ExtractedTitle, - st.UserInput, - deadlineClue, - deadlineText, - ) - - raw, callErr := callModelForJSON(ctx, input.Model, QuickNotePriorityPrompt, prompt) - if callErr != nil { - fallback := fallbackPriority(st) - st.ExtractedPriority = fallback - st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略" - return st, nil - } - - parsed, parseErr := parseJSONPayload[quickNotePriorityModelOutput](raw) - if parseErr != nil || !IsValidTaskPriority(parsed.PriorityGroup) { - fallback := fallbackPriority(st) - st.ExtractedPriority = fallback - st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略" - return st, nil - } - - st.ExtractedPriority = parsed.PriorityGroup - st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason) - return st, nil - })); err != nil { - return nil, err - } - - // 节点3:调用“写库工具”执行持久化。 - if err = graph.AddLambdaNode(quickNoteGraphNodePersist, compose.InvokableLambda( - func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { - if st == nil { - return nil, errors.New("quick note graph: nil state in persist node") - } - if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { - return st, nil - } - - emitStage("quick_note.persisting", "正在写入任务数据。") - - priority := st.ExtractedPriority - if !IsValidTaskPriority(priority) { - priority = fallbackPriority(st) - st.ExtractedPriority = priority - } - - deadlineText := "" - if st.ExtractedDeadline != nil { - deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339) - } - - toolInput := QuickNoteCreateTaskToolInput{ - Title: st.ExtractedTitle, - PriorityGroup: priority, - DeadlineAt: deadlineText, - } - rawInput, marshalErr := json.Marshal(toolInput) - if marshalErr != nil { - st.RecordToolError("构造工具参数失败: " + marshalErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。" - emitStage("quick_note.failed", "参数构造失败,未完成写入。") - } - return st, nil - } - - rawOutput, invokeErr := createTaskTool.InvokableRun(ctx, string(rawInput)) - if invokeErr != nil { - st.RecordToolError(invokeErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" - emitStage("quick_note.failed", "多次重试后仍未完成写入。") - } - return st, nil - } - - toolOutput, parseErr := parseJSONPayload[QuickNoteCreateTaskToolOutput](rawOutput) - if parseErr != nil { - st.RecordToolError("解析工具返回失败: " + parseErr.Error()) - if !st.CanRetryTool() { - st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。" - emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。") - } - 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) - } - if IsValidTaskPriority(toolOutput.PriorityGroup) { - st.ExtractedPriority = toolOutput.PriorityGroup - } - reply := strings.TrimSpace(toolOutput.Message) - if reply == "" { - reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, PriorityLabelCN(st.ExtractedPriority)) - } - st.AssistantReply = reply - emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。") - return st, nil - })); err != nil { - return nil, err - } - - if err = graph.AddLambdaNode(quickNoteGraphNodeExit, compose.InvokableLambda( - func(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { - return st, nil - })); err != nil { - return nil, err - } - - if err = graph.AddEdge(compose.START, quickNoteGraphNodeIntent); err != nil { - return nil, err - } - if err = graph.AddBranch(quickNoteGraphNodeIntent, compose.NewGraphBranch( - func(ctx context.Context, st *QuickNoteState) (string, error) { - if st == nil || !st.IsQuickNoteIntent { - return quickNoteGraphNodeExit, nil - } - if strings.TrimSpace(st.DeadlineValidationError) != "" { - return quickNoteGraphNodeExit, nil - } - return quickNoteGraphNodeRank, nil - }, - map[string]bool{quickNoteGraphNodeRank: true, quickNoteGraphNodeExit: true}, - )); err != nil { - return nil, err - } - if err = graph.AddEdge(quickNoteGraphNodeExit, compose.END); err != nil { - return nil, err - } - if err = graph.AddEdge(quickNoteGraphNodeRank, quickNoteGraphNodePersist); err != nil { - return nil, err - } - if err = graph.AddBranch(quickNoteGraphNodePersist, compose.NewGraphBranch( - func(ctx context.Context, st *QuickNoteState) (string, error) { - if st == nil { - return compose.END, nil - } - if st.Persisted { - return compose.END, nil - } - if st.CanRetryTool() { - return quickNoteGraphNodePersist, nil - } - if strings.TrimSpace(st.AssistantReply) == "" { - st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" - } - return compose.END, nil - }, - map[string]bool{quickNoteGraphNodePersist: true, compose.END: true}, - )); err != nil { - return nil, err - } - - maxSteps := input.State.MaxToolRetry + 10 - if maxSteps < 12 { - maxSteps = 12 - } - - runnable, err := graph.Compile(ctx, - compose.WithGraphName("QuickNoteGraph"), - compose.WithMaxRunSteps(maxSteps), - compose.WithNodeTriggerMode(compose.AnyPredecessor), - ) - if err != nil { - return nil, err - } - - return runnable.Invoke(ctx, input.State) -} - -func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) { - if bundle == nil { - return nil, errors.New("tool bundle is nil") - } - if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 { - return nil, errors.New("tool bundle is empty") - } - for idx, info := range bundle.ToolInfos { - if info == nil || info.Name != name { - continue - } - invokable, ok := bundle.Tools[idx].(tool.InvokableTool) - if !ok { - return nil, fmt.Errorf("tool %s is not invokable", name) - } - return invokable, nil - } - return nil, fmt.Errorf("tool %s not found", name) -} - -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), - } - 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 - } - if resp == nil { - return "", errors.New("模型返回为空") - } - content := strings.TrimSpace(resp.Content) - if content == "" { - return "", errors.New("模型返回内容为空") - } - 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 == "" { - return nil, errors.New("empty response") - } - - if strings.HasPrefix(clean, "```") { - clean = strings.TrimPrefix(clean, "```json") - clean = strings.TrimPrefix(clean, "```") - clean = strings.TrimSuffix(clean, "```") - clean = strings.TrimSpace(clean) - } - - var out T - if err := json.Unmarshal([]byte(clean), &out); err == nil { - return &out, nil - } - - obj := extractJSONObject(clean) - if obj == "" { - return nil, fmt.Errorf("no json object found in: %s", clean) - } - if err := json.Unmarshal([]byte(obj), &out); err != nil { - return nil, err - } - return &out, nil -} - -func extractJSONObject(text string) string { - start := strings.Index(text, "{") - end := strings.LastIndex(text, "}") - if start == -1 || end == -1 || end <= start { - return "" - } - return text[start : end+1] -} - -func fallbackPriority(st *QuickNoteState) int { - if st == nil { - return QuickNotePrioritySimpleNotImportant - } - if st.ExtractedDeadline != nil { - if time.Until(*st.ExtractedDeadline) <= 48*time.Hour { - return QuickNotePriorityImportantUrgent - } - return QuickNotePriorityImportantNotUrgent - } - 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/quicknote/graph.go b/backend/agent/quicknote/graph.go new file mode 100644 index 0000000..2186c86 --- /dev/null +++ b/backend/agent/quicknote/graph.go @@ -0,0 +1,144 @@ +package quicknote + +import ( + "context" + "errors" + "strings" + + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/compose" +) + +const ( + // 图节点:意图识别(含聚合规划与时间校验) + quickNoteGraphNodeIntent = "quick_note_intent" + // 图节点:优先级评估(或本地兜底) + quickNoteGraphNodeRank = "quick_note_priority" + // 图节点:持久化(调用写库工具) + quickNoteGraphNodePersist = "quick_note_persist" + // 图节点:退出(用于非随口记/校验失败分支) + quickNoteGraphNodeExit = "quick_note_exit" +) + +// QuickNoteGraphRunInput 是运行“随口记 graph”所需的输入依赖。 +// 说明: +// 1) EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块); +// 2) 不传 EmitStage 时,图逻辑保持静默执行; +// 3) SkipIntentVerification=true 时,表示上游路由已信任 quick_note,可跳过二次意图判定。 +type QuickNoteGraphRunInput struct { + Model *ark.ChatModel + State *QuickNoteState + Deps QuickNoteToolDeps + + SkipIntentVerification bool + EmitStage func(stage, detail string) +} + +// RunQuickNoteGraph 执行“随口记”图编排。 +// 该文件只负责“连线与分支”,节点内部逻辑全部下沉到 nodes.go。 +func RunQuickNoteGraph(ctx context.Context, input QuickNoteGraphRunInput) (*QuickNoteState, error) { + if input.Model == nil { + return nil, errors.New("quick note graph: model is nil") + } + if input.State == nil { + return nil, errors.New("quick note graph: state is nil") + } + if err := input.Deps.validate(); err != nil { + return nil, err + } + + emitStage := func(stage, detail string) { + if input.EmitStage != nil { + input.EmitStage(stage, detail) + } + } + + // 统一初始化“当前时间基准”,避免同一请求内相对时间口径漂移。 + if input.State.RequestNow.IsZero() { + input.State.RequestNow = quickNoteNowToMinute() + } + if strings.TrimSpace(input.State.RequestNowText) == "" { + input.State.RequestNowText = formatQuickNoteTimeToMinute(input.State.RequestNow) + } + + toolBundle, err := BuildQuickNoteToolBundle(ctx, input.Deps) + if err != nil { + return nil, err + } + createTaskTool, err := getInvokableToolByName(toolBundle, ToolNameQuickNoteCreateTask) + if err != nil { + return nil, err + } + runner := newQuickNoteRunner(input, createTaskTool, emitStage) + + graph := compose.NewGraph[*QuickNoteState, *QuickNoteState]() + + if err = graph.AddLambdaNode(quickNoteGraphNodeIntent, compose.InvokableLambda(runner.intentNode)); err != nil { + return nil, err + } + + if err = graph.AddLambdaNode(quickNoteGraphNodeRank, compose.InvokableLambda(runner.priorityNode)); err != nil { + return nil, err + } + + if err = graph.AddLambdaNode(quickNoteGraphNodePersist, compose.InvokableLambda(runner.persistNode)); err != nil { + return nil, err + } + + if err = graph.AddLambdaNode(quickNoteGraphNodeExit, compose.InvokableLambda(runner.exitNode)); err != nil { + return nil, err + } + + // 连线:START -> intent + if err = graph.AddEdge(compose.START, quickNoteGraphNodeIntent); err != nil { + return nil, err + } + + // 分支:intent 后决定去 priority 还是 exit。 + if err = graph.AddBranch(quickNoteGraphNodeIntent, compose.NewGraphBranch( + runner.nextAfterIntent, + map[string]bool{ + quickNoteGraphNodeRank: true, + quickNoteGraphNodeExit: true, + }, + )); err != nil { + return nil, err + } + + // exit 直接结束。 + if err = graph.AddEdge(quickNoteGraphNodeExit, compose.END); err != nil { + return nil, err + } + + // priority -> persist。 + if err = graph.AddEdge(quickNoteGraphNodeRank, quickNoteGraphNodePersist); err != nil { + return nil, err + } + + // persist 后决定“重试 persist”还是结束。 + if err = graph.AddBranch(quickNoteGraphNodePersist, compose.NewGraphBranch( + runner.nextAfterPersist, + map[string]bool{ + quickNoteGraphNodePersist: true, + compose.END: true, + }, + )); err != nil { + return nil, err + } + + maxSteps := input.State.MaxToolRetry + 10 + if maxSteps < 12 { + maxSteps = 12 + } + + runnable, err := graph.Compile(ctx, + compose.WithGraphName("QuickNoteGraph"), + compose.WithMaxRunSteps(maxSteps), + compose.WithNodeTriggerMode(compose.AnyPredecessor), + ) + if err != nil { + return nil, err + } + + return runnable.Invoke(ctx, input.State) +} diff --git a/backend/agent/quicknote/nodes.go b/backend/agent/quicknote/nodes.go new file mode 100644 index 0000000..d119898 --- /dev/null +++ b/backend/agent/quicknote/nodes.go @@ -0,0 +1,554 @@ +package quicknote + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "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" +) + +type quickNoteIntentModelOutput struct { + IsQuickNote bool `json:"is_quick_note"` + Title string `json:"title"` + DeadlineAt string `json:"deadline_at"` + Reason string `json:"reason"` +} + +type quickNotePriorityModelOutput struct { + PriorityGroup int `json:"priority_group"` + Reason string `json:"reason"` +} + +// quickNotePlanModelOutput 是“单请求聚合规划”节点的模型输出。 +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"` +} + +// runQuickNoteIntentNode 负责“意图识别 + 聚合规划 + 时间校验”。 +// 说明: +// 1) trustRoute 命中时,直接走单请求聚合规划,跳过二次意图识别; +// 2) 无论是否走快路径,最终都要走本地时间硬校验,防止脏时间落库。 +func runQuickNoteIntentNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) { + if st == nil { + 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 +用户输入:%s +请仅输出 JSON(不要 markdown,不要解释),字段如下: +{ + "is_quick_note": boolean, + "title": string, + "deadline_at": string, + "reason": string +} +字段约束: +1) deadline_at 只允许输出绝对时间,格式必须为 "yyyy-MM-dd HH:mm"。 +2) 如果用户说了“明天/后天/下周一/今晚”等相对时间,必须基于上面的当前时间换算成绝对时间。 +3) 如果用户没有提及时间,deadline_at 输出空字符串。`, + st.RequestNowText, + st.UserInput, + ) + + raw, callErr := callModelForJSON(ctx, input.Model, QuickNoteIntentPrompt, prompt) + if callErr != nil { + st.IsQuickNoteIntent = false + st.IntentJudgeReason = "意图识别失败,回退普通聊天" + return st, nil + } + + parsed, parseErr := parseJSONPayload[quickNoteIntentModelOutput](raw) + if parseErr != nil { + st.IsQuickNoteIntent = false + st.IntentJudgeReason = "意图识别结果不可解析,回退普通聊天" + return st, nil + } + + st.IsQuickNoteIntent = parsed.IsQuickNote + st.IntentJudgeReason = strings.TrimSpace(parsed.Reason) + if !st.IsQuickNoteIntent { + return st, nil + } + + title := strings.TrimSpace(parsed.Title) + if title == "" { + title = strings.TrimSpace(st.UserInput) + } + st.ExtractedTitle = title + + emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") + + // Step A:优先尝试解析模型抽取出来的 deadline。 + st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt) + if st.ExtractedDeadlineText != "" { + if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil { + st.ExtractedDeadline = deadline + } + } + + // Step B:基于用户原句执行“本地时间解析 + 合法性校验”。 + 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 st.ExtractedDeadline == nil && userDeadline != nil { + st.ExtractedDeadline = userDeadline + if st.ExtractedDeadlineText == "" { + st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) + } + } + return st, nil +} + +// runQuickNotePriorityNode 负责“优先级评估”。 +// 说明: +// 1) 聚合规划已给出合法优先级时直接复用; +// 2) 快路径下缺失优先级时直接本地兜底,避免额外模型调用; +// 3) 其余场景走独立评估模型,失败再兜底。 +func runQuickNotePriorityNode(ctx context.Context, st *QuickNoteState, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) { + if st == nil { + return nil, errors.New("quick note graph: nil state in priority node") + } + if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { + 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 := "无" + if st.ExtractedDeadline != nil { + deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline) + } + deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText) + if deadlineClue == "" { + deadlineClue = "无" + } + + prompt := fmt.Sprintf(`当前时间(北京时间,精确到分钟):%s +请对以下任务评估优先级: +- 任务标题:%s +- 用户原始输入:%s +- 时间线索原文:%s +- 归一化截止时间:%s + +请仅输出 JSON(不要 markdown,不要解释): +{ + "priority_group": 1|2|3|4, + "reason": "简短理由" +}`, + st.RequestNowText, + st.ExtractedTitle, + st.UserInput, + deadlineClue, + deadlineText, + ) + + raw, callErr := callModelForJSON(ctx, input.Model, QuickNotePriorityPrompt, prompt) + if callErr != nil { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略" + return st, nil + } + + parsed, parseErr := parseJSONPayload[quickNotePriorityModelOutput](raw) + if parseErr != nil || !IsValidTaskPriority(parsed.PriorityGroup) { + st.ExtractedPriority = fallbackPriority(st) + st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略" + return st, nil + } + + st.ExtractedPriority = parsed.PriorityGroup + st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason) + return st, nil +} + +// runQuickNotePersistNodeInternal 负责“写库工具调用 + 重试态回填”。 +func runQuickNotePersistNodeInternal(ctx context.Context, st *QuickNoteState, createTaskTool tool.InvokableTool, input QuickNoteGraphRunInput, emitStage func(stage, detail string)) (*QuickNoteState, error) { + _ = input // 保留参数形状,后续若需要基于输入开关扩展可直接使用。 + + if st == nil { + return nil, errors.New("quick note graph: nil state in persist node") + } + if !st.IsQuickNoteIntent || strings.TrimSpace(st.DeadlineValidationError) != "" { + return st, nil + } + + emitStage("quick_note.persisting", "正在写入任务数据。") + priority := st.ExtractedPriority + if !IsValidTaskPriority(priority) { + priority = fallbackPriority(st) + st.ExtractedPriority = priority + } + + deadlineText := "" + if st.ExtractedDeadline != nil { + deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339) + } + + toolInput := QuickNoteCreateTaskToolInput{ + Title: st.ExtractedTitle, + PriorityGroup: priority, + DeadlineAt: deadlineText, + } + rawInput, marshalErr := json.Marshal(toolInput) + if marshalErr != nil { + st.RecordToolError("构造工具参数失败: " + marshalErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。" + emitStage("quick_note.failed", "参数构造失败,未完成写入。") + } + return st, nil + } + + rawOutput, invokeErr := createTaskTool.InvokableRun(ctx, string(rawInput)) + if invokeErr != nil { + st.RecordToolError(invokeErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" + emitStage("quick_note.failed", "多次重试后仍未完成写入。") + } + return st, nil + } + + toolOutput, parseErr := parseJSONPayload[QuickNoteCreateTaskToolOutput](rawOutput) + if parseErr != nil { + st.RecordToolError("解析工具返回失败: " + parseErr.Error()) + if !st.CanRetryTool() { + st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。" + emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。") + } + return st, nil + } + + // 成功判定硬门槛:必须拿到有效 task_id,防止“假成功”。 + 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) + } + if IsValidTaskPriority(toolOutput.PriorityGroup) { + st.ExtractedPriority = toolOutput.PriorityGroup + } + + reply := strings.TrimSpace(toolOutput.Message) + if reply == "" { + reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, PriorityLabelCN(st.ExtractedPriority)) + } + st.AssistantReply = reply + emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。") + return st, nil +} + +// selectQuickNoteNextAfterIntent 根据意图与时间校验结果决定 intent 后分支。 +func selectQuickNoteNextAfterIntent(st *QuickNoteState) string { + if st == nil || !st.IsQuickNoteIntent { + return quickNoteGraphNodeExit + } + if strings.TrimSpace(st.DeadlineValidationError) != "" { + return quickNoteGraphNodeExit + } + return quickNoteGraphNodeRank +} + +// selectQuickNoteNextAfterPersist 根据持久化状态决定 persist 后分支。 +func selectQuickNoteNextAfterPersist(st *QuickNoteState) string { + if st == nil { + return compose.END + } + if st.Persisted { + return compose.END + } + if st.CanRetryTool() { + return quickNoteGraphNodePersist + } + if strings.TrimSpace(st.AssistantReply) == "" { + st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" + } + return compose.END +} + +func getInvokableToolByName(bundle *QuickNoteToolBundle, name string) (tool.InvokableTool, error) { + if bundle == nil { + return nil, errors.New("tool bundle is nil") + } + if len(bundle.Tools) == 0 || len(bundle.ToolInfos) == 0 { + return nil, errors.New("tool bundle is empty") + } + for idx, info := range bundle.ToolInfos { + if info == nil || info.Name != name { + continue + } + invokable, ok := bundle.Tools[idx].(tool.InvokableTool) + if !ok { + return nil, fmt.Errorf("tool %s is not invokable", name) + } + return invokable, nil + } + return nil, fmt.Errorf("tool %s not found", name) +} + +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), + } + 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 + } + if resp == nil { + return "", errors.New("模型返回为空") + } + content := strings.TrimSpace(resp.Content) + if content == "" { + return "", errors.New("模型返回内容为空") + } + return content, nil +} + +type quickNotePlannedResult struct { + Title string + Deadline *time.Time + DeadlineText string + PriorityGroup int + PriorityReason string + Banter string +} + +// planQuickNoteInSingleCall 在一次模型调用里完成“时间/优先级/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 == "" { + return nil, errors.New("empty response") + } + + if strings.HasPrefix(clean, "```") { + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + } + + var out T + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + + obj := extractJSONObject(clean) + if obj == "" { + return nil, fmt.Errorf("no json object found in: %s", clean) + } + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + return &out, nil +} + +func extractJSONObject(text string) string { + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start == -1 || end == -1 || end <= start { + return "" + } + return text[start : end+1] +} + +func fallbackPriority(st *QuickNoteState) int { + if st == nil { + return QuickNotePrioritySimpleNotImportant + } + if st.ExtractedDeadline != nil { + if time.Until(*st.ExtractedDeadline) <= 48*time.Hour { + return QuickNotePriorityImportantUrgent + } + return QuickNotePriorityImportantNotUrgent + } + return QuickNotePrioritySimpleNotImportant +} + +// deriveQuickNoteTitleFromInput 在“跳过二次意图判定”场景下,从用户原句提取任务标题。 +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 + } + } + + 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_prompt.go b/backend/agent/quicknote/prompt.go similarity index 99% rename from backend/agent/quick_note_prompt.go rename to backend/agent/quicknote/prompt.go index 1bddd8d..db60918 100644 --- a/backend/agent/quick_note_prompt.go +++ b/backend/agent/quicknote/prompt.go @@ -1,4 +1,4 @@ -package agent +package quicknote const ( // QuickNoteRouteControlPrompt 用于“首段控制码分流”: diff --git a/backend/agent/quick_note_graph_test.go b/backend/agent/quicknote/quick_note_graph_test.go similarity index 97% rename from backend/agent/quick_note_graph_test.go rename to backend/agent/quicknote/quick_note_graph_test.go index 7435f97..62b2c54 100644 --- a/backend/agent/quick_note_graph_test.go +++ b/backend/agent/quicknote/quick_note_graph_test.go @@ -1,4 +1,4 @@ -package agent +package quicknote import "testing" diff --git a/backend/agent/quicknote/runner.go b/backend/agent/quicknote/runner.go new file mode 100644 index 0000000..3a85141 --- /dev/null +++ b/backend/agent/quicknote/runner.go @@ -0,0 +1,53 @@ +package quicknote + +import ( + "context" + + "github.com/cloudwego/eino/components/tool" +) + +// quickNoteRunner 是“单次图运行”的请求级依赖容器。 +// 设计目标: +// 1) 把节点运行所需依赖(input/tool/emit)就近收口; +// 2) 让 graph.go 只保留“节点连线”和“方法引用”,提升可读性; +// 3) 避免在 graph.go 里重复出现内联闭包和参数透传。 +type quickNoteRunner struct { + input QuickNoteGraphRunInput + createTaskTool tool.InvokableTool + emitStage func(stage, detail string) +} + +func newQuickNoteRunner(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool, emitStage func(stage, detail string)) *quickNoteRunner { + return &quickNoteRunner{ + input: input, + createTaskTool: createTaskTool, + emitStage: emitStage, + } +} + +func (r *quickNoteRunner) intentNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { + return runQuickNoteIntentNode(ctx, st, r.input, r.emitStage) +} + +func (r *quickNoteRunner) priorityNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { + return runQuickNotePriorityNode(ctx, st, r.input, r.emitStage) +} + +func (r *quickNoteRunner) persistNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { + return runQuickNotePersistNodeInternal(ctx, st, r.createTaskTool, r.input, r.emitStage) +} + +func (r *quickNoteRunner) nextAfterIntent(ctx context.Context, st *QuickNoteState) (string, error) { + _ = ctx + return selectQuickNoteNextAfterIntent(st), nil +} + +func (r *quickNoteRunner) nextAfterPersist(ctx context.Context, st *QuickNoteState) (string, error) { + _ = ctx + return selectQuickNoteNextAfterPersist(st), nil +} + +func (r *quickNoteRunner) exitNode(ctx context.Context, st *QuickNoteState) (*QuickNoteState, error) { + _ = ctx + return st, nil +} diff --git a/backend/agent/state.go b/backend/agent/quicknote/state.go similarity index 99% rename from backend/agent/state.go rename to backend/agent/quicknote/state.go index bf7b465..b7e5e3b 100644 --- a/backend/agent/state.go +++ b/backend/agent/quicknote/state.go @@ -1,4 +1,4 @@ -package agent +package quicknote import "time" diff --git a/backend/agent/tool.go b/backend/agent/quicknote/tool.go similarity index 99% rename from backend/agent/tool.go rename to backend/agent/quicknote/tool.go index be7d75e..049beb1 100644 --- a/backend/agent/tool.go +++ b/backend/agent/quicknote/tool.go @@ -1,4 +1,4 @@ -package agent +package quicknote import ( "context" diff --git a/backend/agent/tool_deadline_test.go b/backend/agent/quicknote/tool_deadline_test.go similarity index 99% rename from backend/agent/tool_deadline_test.go rename to backend/agent/quicknote/tool_deadline_test.go index df9f37c..acf974d 100644 --- a/backend/agent/tool_deadline_test.go +++ b/backend/agent/quicknote/tool_deadline_test.go @@ -1,4 +1,4 @@ -package agent +package quicknote import ( "testing" diff --git a/backend/agent/route/route.go b/backend/agent/route/route.go new file mode 100644 index 0000000..0dfdfc3 --- /dev/null +++ b/backend/agent/route/route.go @@ -0,0 +1,193 @@ +package route + +import ( + "context" + "fmt" + "log" + "regexp" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/agent/quicknote" + "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 ( + // ControlTimeout 是“模型控制码分流”步骤的额外子超时。 + // 设为 0 表示完全跟随父请求上下文,不额外截断。 + ControlTimeout = 0 * time.Second +) + +var ( + // 控制头格式: + // + routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note|chat)["']?[^>]*>`) + // 可选理由块: + // ... + routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`) +) + +// Action 表示控制码路由动作。 +type Action string + +const ( + ActionChat Action = "chat" + ActionQuickNote Action = "quick_note" +) + +// ControlDecision 是“控制码解析结果”。 +type ControlDecision struct { + Action Action + Reason string + Raw string +} + +// RoutingDecision 是服务层最终使用的路由结果。 +type RoutingDecision struct { + EnterQuickNote bool + TrustRoute bool + Detail string +} + +// DecideQuickNoteRouting 通过“模型控制码”决定本次请求走向。 +// 返回语义: +// 1) EnterQuickNote=true:进入 quick_note graph; +// 2) TrustRoute=true:表示可跳过 graph 二次意图判定。 +func DecideQuickNoteRouting(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) RoutingDecision { + decision, err := 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(), ControlTimeout.Milliseconds()) + } else { + log.Printf("quick note 路由控制码失败,进入 graph 兜底: err=%v parent_deadline=none route_timeout_ms=%d", + err, ControlTimeout.Milliseconds()) + } + return RoutingDecision{ + EnterQuickNote: true, + TrustRoute: false, + Detail: "路由判定暂不可用,已进入任务识别兜底流程。", + } + } + + switch decision.Action { + case ActionQuickNote: + reason := strings.TrimSpace(decision.Reason) + if reason == "" { + reason = "模型识别到任务安排请求,准备执行随口记。" + } + return RoutingDecision{ + EnterQuickNote: true, + TrustRoute: true, + Detail: reason, + } + case ActionChat: + return RoutingDecision{ + EnterQuickNote: false, + TrustRoute: false, + Detail: "", + } + default: + log.Printf("quick note 未知路由动作,进入 graph 兜底: action=%s raw=%s", decision.Action, decision.Raw) + return RoutingDecision{ + EnterQuickNote: true, + TrustRoute: false, + Detail: "路由结果异常,已进入任务识别兜底流程。", + } + } +} + +func routeByModelControlTag(ctx context.Context, selectedModel *ark.ChatModel, userMessage string) (*ControlDecision, error) { + if selectedModel == nil { + return nil, fmt.Errorf("model is nil") + } + + nonce := strings.ToLower(strings.ReplaceAll(uuid.NewString(), "-", "")) + routeCtx, cancel := deriveRouteControlContext(ctx, ControlTimeout) + 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(quicknote.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") + } + + return ParseQuickNoteRouteControlTag(raw, nonce) +} + +// deriveRouteControlContext 为“控制码路由”创建子上下文。 +// 设计要点: +// 1) timeout<=0 时不加额外 deadline,仅继承父上下文; +// 2) 父 ctx deadline 更紧时,沿用父上下文,避免过早超时误判。 +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 解析控制码返回。 +// 容错策略: +// 1) 允许大小写、属性顺序、额外属性差异; +// 2) nonce 必须精确匹配; +// 3) action 仅允许 quick_note/chat。 +func ParseQuickNoteRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { + text := strings.TrimSpace(raw) + if text == "" { + return nil, fmt.Errorf("route content is empty") + } + + header := routeHeaderRegex.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 := Action(actionText) + if action != ActionQuickNote && action != ActionChat { + return nil, fmt.Errorf("invalid route action: %s", actionText) + } + + reason := "" + reasonMatch := routeReasonRegex.FindStringSubmatch(text) + if len(reasonMatch) >= 2 { + reason = strings.TrimSpace(reasonMatch[1]) + } + + return &ControlDecision{ + Action: action, + Reason: reason, + Raw: text, + }, nil +} diff --git a/backend/service/agent.go b/backend/service/agent.go index 84ab1a6..4b8afef 100644 --- a/backend/service/agent.go +++ b/backend/service/agent.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/LoveLosita/smartflow/backend/agent" + "github.com/LoveLosita/smartflow/backend/agent/chat" "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/dao" "github.com/LoveLosita/smartflow/backend/inits" @@ -103,7 +103,7 @@ func (s *AgentService) runNormalChatFlow( chatHistory = conv.ToEinoMessages(histories) } - historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, agent.SystemPrompt, userMessage) + historyBudget := pkg.HistoryTokenBudgetByModel(resolvedModelName, chat.SystemPrompt, userMessage) trimmedHistory, totalHistoryTokens, keptHistoryTokens, droppedCount := pkg.TrimHistoryByTokenBudget(chatHistory, historyBudget) chatHistory = trimmedHistory @@ -127,7 +127,7 @@ func (s *AgentService) runNormalChatFlow( } } - fullText, streamErr := agent.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart) + fullText, streamErr := chat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart) if streamErr != nil { pushErrNonBlocking(errChan, streamErr) return @@ -147,6 +147,12 @@ func (s *AgentService) runNormalChatFlow( return } + // 普通聊天链路也需要把助手回复写入 Redis, + // 否则会出现“数据库有助手消息,但 Redis 最新会话只有用户消息”的口径不一致。 + if err = s.agentCache.PushMessage(context.Background(), chatID, &schema.Message{Role: schema.Assistant, Content: fullText}); err != nil { + log.Printf("写入助手消息到 Redis 失败: %v", err) + } + if saveErr := s.saveChatHistoryReliable(context.Background(), model.ChatHistoryPersistPayload{ UserID: userID, ConversationID: chatID, diff --git a/backend/service/agent_quick_note.go b/backend/service/agent_quick_note.go index b07d018..fdc52ed 100644 --- a/backend/service/agent_quick_note.go +++ b/backend/service/agent_quick_note.go @@ -4,11 +4,12 @@ import ( "context" "fmt" "log" - "regexp" "strings" "time" - "github.com/LoveLosita/smartflow/backend/agent" + "github.com/LoveLosita/smartflow/backend/agent/chat" + "github.com/LoveLosita/smartflow/backend/agent/quicknote" + "github.com/LoveLosita/smartflow/backend/agent/route" "github.com/LoveLosita/smartflow/backend/model" "github.com/cloudwego/eino-ext/components/model/ark" einoModel "github.com/cloudwego/eino/components/model" @@ -17,51 +18,9 @@ import ( 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 -} +// quickNoteRoutingDecision 只是路由层结果的本地别名。 +// 保留这个别名是为了尽量少改调用侧(agent.go 中的字段访问保持不变)。 +type quickNoteRoutingDecision = route.RoutingDecision // quickNoteProgressEmitter 负责把“链路阶段状态”伪装成 OpenAI 兼容的 reasoning_content chunk。 // 设计目标: @@ -92,8 +51,8 @@ func newQuickNoteProgressEmitter(outChan chan<- string, modelName string, enable // Emit 按“阶段 + 说明”输出 reasoning_content。 // 注意: -// - 这里不输出 role,避免和后续正文的 role 块冲突; -// - 即使发送失败,也只记录日志,不影响主流程继续执行。 +// 1) 这里不输出 role,避免和后续正文 role 块冲突; +// 2) 即使发送失败,也只记录日志,不影响主流程继续执行。 func (e *quickNoteProgressEmitter) Emit(stage, detail string) { if e == nil || !e.enablePush || e.outChan == nil { return @@ -109,7 +68,7 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) { reasoning += "\n" + detail } - chunk, err := agent.ToOpenAIStream(&schema.Message{ReasoningContent: reasoning}, e.requestID, e.modelName, e.created, false) + chunk, err := chat.ToOpenAIStream(&schema.Message{ReasoningContent: reasoning}, e.requestID, e.modelName, e.created, false) if err != nil { log.Printf("输出随口记阶段状态失败 stage=%s err=%v", stage, err) return @@ -121,11 +80,9 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) { // tryHandleQuickNoteWithGraph 尝试用“随口记 graph”处理本次用户输入。 // 返回值语义: -// - handled=true:本次请求已在随口记链路处理完成(成功/失败都会返回文案); -// - handled=false:不是随口记意图,调用方应回落普通聊天链路; -// - state:用于拼接最终“一次性正文回复”。 -// 参数说明: -// - trustRoute=true:信任上游控制码,graph 跳过二次意图判定,直接进入时间校验/优先级/写库流程。 +// 1) handled=true:本次请求已在随口记链路处理完成(成功/失败都会返回文案); +// 2) handled=false:不是随口记意图,调用方应回落普通聊天链路; +// 3) state:用于拼接最终“一次性正文回复”。 func (s *AgentService) tryHandleQuickNoteWithGraph( ctx context.Context, selectedModel *ark.ChatModel, @@ -135,20 +92,20 @@ func (s *AgentService) tryHandleQuickNoteWithGraph( traceID string, trustRoute bool, emitStage func(stage, detail string), -) (handled bool, state *agent.QuickNoteState, err error) { +) (handled bool, state *quicknote.QuickNoteState, err error) { if s.taskRepo == nil || selectedModel == nil { return false, nil, nil } - state = agent.NewQuickNoteState(traceID, userID, chatID, userMessage) - finalState, runErr := agent.RunQuickNoteGraph(ctx, agent.QuickNoteGraphRunInput{ + state = quicknote.NewQuickNoteState(traceID, userID, chatID, userMessage) + finalState, runErr := quicknote.RunQuickNoteGraph(ctx, quicknote.QuickNoteGraphRunInput{ Model: selectedModel, State: state, - Deps: agent.QuickNoteToolDeps{ + Deps: quicknote.QuickNoteToolDeps{ ResolveUserID: func(ctx context.Context) (int, error) { return userID, nil }, - CreateTask: func(ctx context.Context, req agent.QuickNoteCreateTaskRequest) (*agent.QuickNoteCreateTaskResult, error) { + CreateTask: func(ctx context.Context, req quicknote.QuickNoteCreateTaskRequest) (*quicknote.QuickNoteCreateTaskResult, error) { taskModel := &model.Task{ UserID: req.UserID, Title: req.Title, @@ -160,7 +117,7 @@ func (s *AgentService) tryHandleQuickNoteWithGraph( if createErr != nil { return nil, createErr } - return &agent.QuickNoteCreateTaskResult{ + return &quicknote.QuickNoteCreateTaskResult{ TaskID: created.ID, Title: created.Title, PriorityGroup: created.Priority, @@ -177,14 +134,13 @@ func (s *AgentService) tryHandleQuickNoteWithGraph( if finalState == nil || !finalState.IsQuickNoteIntent { return false, nil, nil } - return true, finalState, nil } // emitSingleAssistantCompletion 将单条完整回复包装成 OpenAI 兼容 chunk 流并写入 outChan。 // 说明: -// - 保持现有 OpenAI 兼容格式不变; -// - 正文只发一次,不做伪分段。 +// 1) 保持现有 OpenAI 兼容格式不变; +// 2) 正文只发一次,不做伪分段。 func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply string) error { if strings.TrimSpace(modelName) == "" { modelName = "worker" @@ -192,7 +148,7 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin requestID := "chatcmpl-" + uuid.NewString() created := time.Now().Unix() - chunk, err := agent.ToOpenAIStream(&schema.Message{Role: schema.Assistant, Content: reply}, requestID, modelName, created, true) + chunk, err := chat.ToOpenAIStream(&schema.Message{Role: schema.Assistant, Content: reply}, requestID, modelName, created, true) if err != nil { return err } @@ -200,7 +156,7 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin outChan <- chunk } - finishChunk, err := agent.ToOpenAIFinishStream(requestID, modelName, created) + finishChunk, err := chat.ToOpenAIFinishStream(requestID, modelName, created) if err != nil { return err } @@ -212,9 +168,9 @@ func emitSingleAssistantCompletion(outChan chan<- string, modelName, reply strin // buildQuickNoteFinalReply 生成最终的一次性正文回复。 // 组合策略: // 1) 任务事实(标题/优先级/截止时间)由后端拼接,确保准确; -// 2) 轻松跟进句交给 AI 生成,贴合用户话题(避免硬编码“薯饼”这类场景分支); +// 2) 轻松跟进句交给 AI 生成,贴合用户话题; // 3) AI 生成失败时自动降级为固定友好文案,保证稳定可用。 -func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *agent.QuickNoteState) string { +func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, userMessage string, state *quicknote.QuickNoteState) string { if state == nil { return "我这次没成功记上,别急,再发我一次我马上补上。" } @@ -227,8 +183,8 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, } priorityText := "已安排优先级" - if agent.IsValidTaskPriority(state.ExtractedPriority) { - priorityText = fmt.Sprintf("优先级:%s", agent.PriorityLabelCN(state.ExtractedPriority)) + if quicknote.IsValidTaskPriority(state.ExtractedPriority) { + priorityText = fmt.Sprintf("优先级:%s", quicknote.PriorityLabelCN(state.ExtractedPriority)) } deadlineText := "" @@ -237,14 +193,10 @@ 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 + " 已帮你稳稳记下,放心推进。" } @@ -270,9 +222,9 @@ func buildQuickNoteFinalReply(ctx context.Context, selectedModel *ark.ChatModel, // generateQuickNoteBanter 让模型根据用户原话生成一条“贴题轻松句”。 // 约束: -// - 只生成跟进语气,不承担事实表达; -// - 不得改动任务事实; -// - 输出控制在一句,方便直接拼接在事实句后。 +// 1) 只生成跟进语气,不承担事实表达; +// 2) 不得改动任务事实; +// 3) 输出控制在一句,方便直接拼接在事实句后。 func generateQuickNoteBanter( ctx context.Context, selectedModel *ark.ChatModel, @@ -299,7 +251,7 @@ func generateQuickNoteBanter( ) messages := []*schema.Message{ - schema.SystemMessage(agent.QuickNoteReplyBanterPrompt), + schema.SystemMessage(quicknote.QuickNoteReplyBanterPrompt), schema.UserMessage(prompt), } @@ -320,8 +272,6 @@ func generateQuickNoteBanter( if text == "" { return "", fmt.Errorf("empty content") } - - // 简单兜底:只保留首行,避免模型输出多段。 if idx := strings.Index(text, "\n"); idx >= 0 { text = strings.TrimSpace(text[:idx]) } @@ -329,162 +279,10 @@ func generateQuickNoteBanter( } // decideQuickNoteRouting 决定当前输入是否进入“随口记 graph”。 -// 新策略:改为“模型控制码分流”,不再依赖关键词和本地猜测。 -// -// 处理流程: -// 1) 先调用路由模型拿控制码(quick_note / chat); -// 2) 控制码可解析时按模型判定分流; -// 3) 控制码超时/解析失败时,进入随口记 graph 做兜底意图识别,避免遗漏任务。 -// -// 返回值说明: -// - EnterQuickNote=true:进入随口记 graph; -// - TrustRoute=true:跳过 graph 内二次意图判定; -// - Detail:用于阶段推送,向前端解释“为何进入该分支”。 +// 该函数只是服务层薄封装,具体控制码解析逻辑已下沉到 agent/route 包。 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: "路由判定暂不可用,已进入任务识别兜底流程。", - } - } - - 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 + _ = s + return route.DecideQuickNoteRouting(ctx, selectedModel, userMessage) } // persistChatAfterReply 在“随口记 graph”返回后,复用当前项目的后置持久化策略: diff --git a/backend/service/agent_quick_note_route_test.go b/backend/service/agent_quick_note_route_test.go index 996ba4d..6f9ee0e 100644 --- a/backend/service/agent_quick_note_route_test.go +++ b/backend/service/agent_quick_note_route_test.go @@ -4,7 +4,8 @@ import ( "strings" "testing" - "github.com/LoveLosita/smartflow/backend/agent" + "github.com/LoveLosita/smartflow/backend/agent/quicknote" + "github.com/LoveLosita/smartflow/backend/agent/route" ) // TestParseQuickNoteRouteControlTag_QuickNote @@ -15,15 +16,15 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) { raw := ` 用户明确在请求未来提醒` - decision, err := parseQuickNoteRouteControlTag(raw, nonce) + decision, err := route.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 decision.Action != route.ActionQuickNote { + t.Fatalf("action 解析错误,期望=%s 实际=%s", route.ActionQuickNote, decision.Action) } if strings.TrimSpace(decision.Reason) == "" { t.Fatalf("reason 不应为空") @@ -34,7 +35,7 @@ func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) { // 目的:确保 nonce 不匹配时直接报错,避免把非本次请求的控制码当作有效路由。 func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) { raw := `` - if _, err := parseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil { + if _, err := route.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil { t.Fatalf("期望 nonce 不匹配时报错,但未报错") } } @@ -42,7 +43,7 @@ func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) { // TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID // 目的:即使 state.Persisted 被错误置为 true,只要 task_id 无效,也不能返回“安排成功”文案。 func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) { - state := &agent.QuickNoteState{ + state := &quicknote.QuickNoteState{ Persisted: true, PersistedTaskID: 0, ExtractedTitle: "去下馆子", @@ -57,7 +58,7 @@ func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) { // TestBuildQuickNoteFinalReply_UseExtractedBanter // 目的:当聚合规划阶段已经产出 banter 时,最终回复应直接复用,避免再次调用润色模型。 func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) { - state := &agent.QuickNoteState{ + state := &quicknote.QuickNoteState{ Persisted: true, PersistedTaskID: 12, ExtractedTitle: "明天去取快递",