package agentnode import ( "context" "encoding/json" "errors" "fmt" "strings" "time" agentllm "github.com/LoveLosita/smartflow/backend/agent/llm" agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" "github.com/cloudwego/eino-ext/components/model/ark" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/compose" ) const ( // QuickNoteGraphNodeIntent 是随口记图中的“意图识别”节点名。 QuickNoteGraphNodeIntent = "quick_note_intent" // QuickNoteGraphNodeRank 是随口记图中的“优先级评估”节点名。 QuickNoteGraphNodeRank = "quick_note_priority" // QuickNoteGraphNodePersist 是随口记图中的“持久化写库”节点名。 QuickNoteGraphNodePersist = "quick_note_persist" // QuickNoteGraphNodeExit 是随口记图中的“提前退出”节点名。 QuickNoteGraphNodeExit = "quick_note_exit" ) // QuickNoteGraphRunInput 描述一次随口记图运行所需的请求级依赖。 // // 职责边界: // 1. 负责把模型、初始状态、工具依赖和阶段回调打包给 graph 层。 // 2. 不负责做依赖校验,校验逻辑由 graph/node 构造阶段处理。 type QuickNoteGraphRunInput struct { Model *ark.ChatModel State *agentmodel.QuickNoteState Deps QuickNoteToolDeps SkipIntentVerification bool EmitStage func(stage, detail string) } // QuickNoteNodes 是随口记图的节点容器。 // // 职责边界: // 1. 负责承接节点运行时依赖,并向 graph 暴露可直接挂载的方法。 // 2. 不负责 graph 编译,也不负责 service 层接口接线。 type QuickNoteNodes struct { input QuickNoteGraphRunInput createTaskTool tool.InvokableTool emitStage func(stage, detail string) } // NewQuickNoteNodes 负责构造随口记节点容器。 // // 输入输出语义: // 1. createTaskTool 不能为空,否则 persist 节点无法落库。 // 2. EmitStage 为空时会回退到空实现,避免节点内部到处判空。 func NewQuickNoteNodes(input QuickNoteGraphRunInput, createTaskTool tool.InvokableTool) (*QuickNoteNodes, error) { if createTaskTool == nil { return nil, errors.New("quick note nodes: createTaskTool is nil") } emitStage := input.EmitStage if emitStage == nil { emitStage = func(stage, detail string) {} } return &QuickNoteNodes{ input: input, createTaskTool: createTaskTool, emitStage: emitStage, }, nil } // Exit 是图中的显式退出节点。 // // 职责边界: // 1. 仅作为图收口占位,保持状态原样透传。 // 2. 不做额外业务处理,避免退出节点再引入副作用。 func (n *QuickNoteNodes) Exit(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { _ = ctx return st, nil } // NextAfterIntent 根据意图识别结果决定 intent 节点后的分支走向。 // // 步骤说明: // 1. 非随口记意图时直接退出,避免误把普通聊天写成任务。 // 2. 截止时间校验失败时同样直接退出,让上层优先把错误提示给用户。 // 3. 只有意图成立且时间合法,才进入优先级评估节点。 func (n *QuickNoteNodes) NextAfterIntent(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { _ = ctx if st == nil || !st.IsQuickNoteIntent { return QuickNoteGraphNodeExit, nil } if st.DeadlineValidationError != "" { return QuickNoteGraphNodeExit, nil } return QuickNoteGraphNodeRank, nil } // NextAfterPersist 根据持久化结果决定 persist 节点后的分支走向。 // // 输入输出语义: // 1. Persisted=true 表示已经成功写库,可以直接结束。 // 2. Persisted=false 且 CanRetryTool()=true 表示继续重试写库。 // 3. 重试用尽后会补齐兜底回复,再结束链路,避免用户拿到空响应。 func (n *QuickNoteNodes) NextAfterPersist(ctx context.Context, st *agentmodel.QuickNoteState) (string, error) { _ = ctx if st == nil { return compose.END, nil } if st.Persisted { return compose.END, nil } if st.CanRetryTool() { return QuickNoteGraphNodePersist, nil } if st.AssistantReply == "" { st.AssistantReply = "抱歉,我已经重试了多次,还是没能成功记录这条任务,请稍后再试。" } return compose.END, nil } // Intent 负责“意图识别 + 聚合规划 + 时间校验”。 // // 职责边界: // 1. 负责判断本次请求是否属于随口记; // 2. 负责把模型规划结果回填到 state; // 3. 负责做最后一层本地时间硬校验,避免非法时间被静默写成 NULL; // 4. 不负责真正写库。 func (n *QuickNoteNodes) Intent(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.QuickNoteState, error) { if st == nil { return nil, errors.New("quick note graph: nil state in intent node") } // 1. 若上游路由已经高置信命中 quick_note,则直接进入单次聚合规划。 // 1.1 目的:尽量把“标题 / 时间 / 优先级 / banter”压缩到一次模型往返内; // 1.2 失败处理:若聚合规划失败,不中断整条链路,而是回退到本地兜底,保证可用性优先。 if n.input.SkipIntentVerification { n.emitStage("quick_note.intent.analyzing", "已由上游路由判定为任务请求,跳过二次意图判断。") st.IsQuickNoteIntent = true st.IntentJudgeReason = "上游路由已命中 quick_note,跳过二次意图判定" st.PlannedBySingleCall = true n.emitStage("quick_note.plan.generating", "正在一次性生成时间归一化、优先级与回复润色。") plan, planErr := planQuickNoteInSingleCall(ctx, n.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 plan.UrgencyThreshold != nil { st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(plan.UrgencyThreshold, plan.Deadline) } if agentmodel.IsValidTaskPriority(plan.PriorityGroup) { st.ExtractedPriority = plan.PriorityGroup st.ExtractedPriorityReason = strings.TrimSpace(plan.PriorityReason) } st.ExtractedBanter = strings.TrimSpace(plan.Banter) } // 1.3 如果聚合规划没能给出标题,则回退到本地标题抽取,避免后续 persist 节点拿到空标题。 if strings.TrimSpace(st.ExtractedTitle) == "" { st.ExtractedTitle = deriveQuickNoteTitleFromInput(st.UserInput) } // 1.4 最后一定要做一轮本地时间硬校验。 // 1.4.1 原因:模型即使给了时间,也可能和用户原句不一致,或者用户原句本身就是非法时间; // 1.4.2 若检测到“用户给了时间线索但格式非法”,直接退出图并给用户明确修正提示。 n.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点。" n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") return st, nil } if userDeadline != nil { st.ExtractedDeadline = userDeadline st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) } return st, nil } // 2. 常规路径:先做一次意图识别,再做本地时间硬校验。 n.emitStage("quick_note.intent.analyzing", "正在分析用户输入是否属于任务安排请求。") parsed, callErr := agentllm.IdentifyQuickNoteIntent(ctx, n.input.Model, st.RequestNowText, st.UserInput) if callErr != nil { // 2.1 这里不直接返回 error,而是把它视为“本次未能确认是 quick note”,交给上层回退普通聊天。 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 n.emitStage("quick_note.deadline.validating", "正在校验并归一化任务时间。") // 2.2 先尝试吃模型返回的 deadline_at,用于减少后续重复推理。 st.ExtractedDeadlineText = strings.TrimSpace(parsed.DeadlineAt) if st.ExtractedDeadlineText != "" { if deadline, deadlineErr := parseOptionalDeadlineWithNow(st.ExtractedDeadlineText, st.RequestNow); deadlineErr == nil { st.ExtractedDeadline = deadline } } // 2.3 再强制对用户原句做一次时间线索校验。 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点。" n.emitStage("quick_note.failed", "时间校验失败,未执行写入。") return st, nil } // 2.4 若模型没提到 deadline,但用户原句能解析出来,则以用户原句为准补齐。 if st.ExtractedDeadline == nil && userDeadline != nil { st.ExtractedDeadline = userDeadline if st.ExtractedDeadlineText == "" { st.ExtractedDeadlineText = strings.TrimSpace(st.UserInput) } } return st, nil } // Priority 负责“优先级评估”。 // // 职责边界: // 1. 负责在 intent 节点之后补齐 priority_group; // 2. 若聚合规划已经给出合法优先级,则直接复用,不再重复调用模型; // 3. 若模型评估失败,则使用本地兜底策略,保证链路继续可走; // 4. 不负责写库。 func (n *QuickNoteNodes) Priority(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.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 } // 1. 聚合规划已经给出合法优先级时,直接复用,避免重复调模型。 if agentmodel.IsValidTaskPriority(st.ExtractedPriority) { if strings.TrimSpace(st.ExtractedPriorityReason) == "" { st.ExtractedPriorityReason = "复用聚合规划优先级" } n.emitStage("quick_note.priority.evaluating", "已复用聚合规划结果中的优先级。") return st, nil } // 2. 单请求聚合路径若没有给出合法 priority,则直接走本地兜底,优先保证低时延。 if n.input.SkipIntentVerification || st.PlannedBySingleCall { st.ExtractedPriority = fallbackPriority(st) st.ExtractedPriorityReason = "聚合规划未给出合法优先级,使用本地兜底" n.emitStage("quick_note.priority.evaluating", "聚合优先级缺失,已使用本地兜底。") return st, nil } n.emitStage("quick_note.priority.evaluating", "正在评估任务优先级。") deadlineText := "无" if st.ExtractedDeadline != nil { deadlineText = formatQuickNoteTimeToMinute(*st.ExtractedDeadline) } deadlineClue := strings.TrimSpace(st.ExtractedDeadlineText) if deadlineClue == "" { deadlineClue = "无" } parsed, callErr := agentllm.PlanQuickNotePriority(ctx, n.input.Model, st.RequestNowText, st.ExtractedTitle, st.UserInput, deadlineClue, deadlineText) if callErr != nil { st.ExtractedPriority = fallbackPriority(st) st.ExtractedPriorityReason = "优先级评估失败,使用兜底策略" return st, nil } if parsed == nil || !agentmodel.IsValidTaskPriority(parsed.PriorityGroup) { st.ExtractedPriority = fallbackPriority(st) st.ExtractedPriorityReason = "优先级结果异常,使用兜底策略" return st, nil } st.ExtractedPriority = parsed.PriorityGroup st.ExtractedPriorityReason = strings.TrimSpace(parsed.Reason) if strings.TrimSpace(parsed.UrgencyThresholdAt) != "" { urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(strings.TrimSpace(parsed.UrgencyThresholdAt), st.RequestNow) if thresholdErr == nil { st.ExtractedUrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, st.ExtractedDeadline) } } return st, nil } // Persist 负责“调工具写库 + 有限次重试状态回填”。 // // 职责边界: // 1. 负责把 state 中已提取出的标题、时间、优先级组装成工具入参; // 2. 负责调用 createTaskTool 执行真正写库; // 3. 负责把成功/失败结果回填到 state,供后续分支与回复使用; // 4. 不负责最终回复润色,不负责 service 层的 Redis 与持久化收尾。 func (n *QuickNoteNodes) Persist(ctx context.Context, st *agentmodel.QuickNoteState) (*agentmodel.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 } n.emitStage("quick_note.persisting", "正在写入任务数据。") priority := st.ExtractedPriority if !agentmodel.IsValidTaskPriority(priority) { priority = fallbackPriority(st) st.ExtractedPriority = priority } deadlineText := "" if st.ExtractedDeadline != nil { deadlineText = st.ExtractedDeadline.In(quickNoteLocation()).Format(time.RFC3339) } urgencyThresholdText := "" if st.ExtractedUrgencyThreshold != nil { urgencyThresholdText = st.ExtractedUrgencyThreshold.In(quickNoteLocation()).Format(time.RFC3339) } toolInput := QuickNoteCreateTaskToolInput{ Title: st.ExtractedTitle, PriorityGroup: priority, DeadlineAt: deadlineText, UrgencyThresholdAt: urgencyThresholdText, } rawInput, marshalErr := json.Marshal(toolInput) if marshalErr != nil { st.RecordToolError("构造工具参数失败: " + marshalErr.Error()) if !st.CanRetryTool() { st.AssistantReply = "抱歉,记录任务时参数处理失败,请稍后重试。" n.emitStage("quick_note.failed", "参数构造失败,未完成写入。") } return st, nil } rawOutput, invokeErr := n.createTaskTool.InvokableRun(ctx, string(rawInput)) if invokeErr != nil { st.RecordToolError(invokeErr.Error()) if !st.CanRetryTool() { st.AssistantReply = "抱歉,我尝试了多次仍未能成功记录这条任务,请稍后再试。" n.emitStage("quick_note.failed", "多次重试后仍未完成写入。") } return st, nil } toolOutput, parseErr := agentllm.ParseJSONObject[QuickNoteCreateTaskToolOutput](rawOutput) if parseErr != nil { st.RecordToolError("解析工具返回失败: " + parseErr.Error()) if !st.CanRetryTool() { st.AssistantReply = "抱歉,我拿到了异常结果,没能确认任务是否记录成功,请稍后再试。" n.emitStage("quick_note.failed", "结果解析异常,无法确认写入结果。") } return st, nil } if toolOutput.TaskID <= 0 { st.RecordToolError(fmt.Sprintf("工具返回非法 task_id=%d", toolOutput.TaskID)) if !st.CanRetryTool() { st.AssistantReply = "抱歉,这次我没能确认任务写入成功,请再发一次我立刻补上。" n.emitStage("quick_note.failed", "写入结果缺少有效 task_id,已终止成功回包。") } return st, nil } // 1. 只有拿到有效 task_id,才视为真正写入成功; // 2. 这样可以避免出现“返回成功文案,但数据库里根本没记录”的假成功。 st.RecordToolSuccess(toolOutput.TaskID) if strings.TrimSpace(toolOutput.Title) != "" { st.ExtractedTitle = strings.TrimSpace(toolOutput.Title) } if agentmodel.IsValidTaskPriority(toolOutput.PriorityGroup) { st.ExtractedPriority = toolOutput.PriorityGroup } reply := strings.TrimSpace(toolOutput.Message) if reply == "" { reply = fmt.Sprintf("已为你记录:%s(%s)", st.ExtractedTitle, agentmodel.PriorityLabelCN(st.ExtractedPriority)) } st.AssistantReply = reply n.emitStage("quick_note.persisted", "任务写入成功,正在组织回复内容。") return st, nil } type quickNotePlannedResult struct { Title string Deadline *time.Time DeadlineText string UrgencyThreshold *time.Time UrgencyThresholdText 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) { parsed, err := agentllm.PlanQuickNoteInSingleCall(ctx, chatModel, nowText, userInput) if err != nil { return nil, err } result := &quickNotePlannedResult{ Title: strings.TrimSpace(parsed.Title), DeadlineText: strings.TrimSpace(parsed.DeadlineAt), UrgencyThresholdText: strings.TrimSpace(parsed.UrgencyThresholdAt), 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 } } if result.UrgencyThresholdText != "" { if urgencyThreshold, thresholdErr := parseOptionalDeadlineWithNow(result.UrgencyThresholdText, now); thresholdErr == nil { result.UrgencyThreshold = normalizeUrgencyThreshold(urgencyThreshold, result.Deadline) } } return result, nil } func normalizeUrgencyThreshold(threshold *time.Time, deadline *time.Time) *time.Time { if threshold == nil { return nil } if deadline == nil { return threshold } if threshold.After(*deadline) { normalized := *deadline return &normalized } return threshold } func fallbackPriority(st *agentmodel.QuickNoteState) int { if st == nil { return agentmodel.QuickNotePrioritySimpleNotImportant } if st.ExtractedDeadline != nil { if time.Until(*st.ExtractedDeadline) <= 48*time.Hour { return agentmodel.QuickNotePriorityImportantUrgent } return agentmodel.QuickNotePriorityImportantNotUrgent } return agentmodel.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 }