diff --git a/backend/agent/scheduleplan/graph.go b/backend/agent/scheduleplan/graph.go index 94a2841..123d491 100644 --- a/backend/agent/scheduleplan/graph.go +++ b/backend/agent/scheduleplan/graph.go @@ -14,14 +14,6 @@ const ( schedulePlanGraphNodePlan = "schedule_plan_plan" // 图节点:调用粗排算法生成候选方案 schedulePlanGraphNodePreview = "schedule_plan_preview" - // 图节点:将候选方案转换为可落库结构 - schedulePlanGraphNodeMaterialize = "schedule_plan_materialize" - // 图节点:执行落库 - schedulePlanGraphNodeApply = "schedule_plan_apply" - // 图节点:分析失败原因并生成修补方案 - schedulePlanGraphNodeReflect = "schedule_plan_reflect" - // 图节点:生成最终回复文案 - schedulePlanGraphNodeFinalize = "schedule_plan_finalize" // 图节点:退出(用于提前终止分支) schedulePlanGraphNodeExit = "schedule_plan_exit" // 图节点:构建混合日程(ReAct 精排前置) @@ -55,13 +47,9 @@ type SchedulePlanGraphRunInput struct { // // 图结构: // -// START -> plan -> [branch] -> preview -> [branch] -> materialize -> [branch] -> apply -> [branch] -// | | | | -// exit exit exit finalize (成功) -// | -// reflect -> [branch] -> apply (重试) -// | -// finalize (放弃) +// START -> plan -> [branch] -> preview -> [branch] -> hybridBuild -> [branch] -> reactRefine -> returnPreview -> END +// | | | +// exit exit exit // // 该文件只负责"连线与分支",节点内部逻辑全部下沉到 nodes.go。 func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) (*SchedulePlanState, error) { @@ -105,18 +93,6 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) if err := graph.AddLambdaNode(schedulePlanGraphNodePreview, compose.InvokableLambda(runner.previewNode)); err != nil { return nil, err } - if err := graph.AddLambdaNode(schedulePlanGraphNodeMaterialize, compose.InvokableLambda(runner.materializeNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(schedulePlanGraphNodeApply, compose.InvokableLambda(runner.applyNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(schedulePlanGraphNodeReflect, compose.InvokableLambda(runner.reflectNode)); err != nil { - return nil, err - } - if err := graph.AddLambdaNode(schedulePlanGraphNodeFinalize, compose.InvokableLambda(runner.finalizeNode)); err != nil { - return nil, err - } if err := graph.AddLambdaNode(schedulePlanGraphNodeExit, compose.InvokableLambda(runner.exitNode)); err != nil { return nil, err } @@ -148,19 +124,18 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) return nil, err } - // 8. preview -> [branch] -> hybridBuild | materialize | exit + // 8. preview -> [branch] -> hybridBuild | exit if err := graph.AddBranch(schedulePlanGraphNodePreview, compose.NewGraphBranch( runner.nextAfterPreview, map[string]bool{ schedulePlanGraphNodeHybridBuild: true, - schedulePlanGraphNodeMaterialize: true, schedulePlanGraphNodeExit: true, }, )); err != nil { return nil, err } - // 8.1 hybridBuild -> [branch] -> reactRefine | exit + // 9. hybridBuild -> [branch] -> reactRefine | exit if err := graph.AddBranch(schedulePlanGraphNodeHybridBuild, compose.NewGraphBranch( runner.nextAfterHybridBuild, map[string]bool{ @@ -171,62 +146,24 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) return nil, err } - // 8.2 reactRefine -> returnPreview(固定边) + // 10. reactRefine -> returnPreview(固定边) if err := graph.AddEdge(schedulePlanGraphNodeReactRefine, schedulePlanGraphNodeReturnPreview); err != nil { return nil, err } - // 8.3 returnPreview -> END + // 11. returnPreview -> END if err := graph.AddEdge(schedulePlanGraphNodeReturnPreview, compose.END); err != nil { return nil, err } - // 9. materialize -> [branch] -> apply | exit - if err := graph.AddBranch(schedulePlanGraphNodeMaterialize, compose.NewGraphBranch( - runner.nextAfterMaterialize, - map[string]bool{ - schedulePlanGraphNodeApply: true, - schedulePlanGraphNodeExit: true, - }, - )); err != nil { - return nil, err - } - - // 10. apply -> [branch] -> finalize | reflect - if err := graph.AddBranch(schedulePlanGraphNodeApply, compose.NewGraphBranch( - runner.nextAfterApply, - map[string]bool{ - schedulePlanGraphNodeFinalize: true, - schedulePlanGraphNodeReflect: true, - }, - )); err != nil { - return nil, err - } - - // 11. reflect -> [branch] -> apply (重试) | finalize (放弃) - if err := graph.AddBranch(schedulePlanGraphNodeReflect, compose.NewGraphBranch( - runner.nextAfterReflect, - map[string]bool{ - schedulePlanGraphNodeApply: true, - schedulePlanGraphNodeFinalize: true, - }, - )); err != nil { - return nil, err - } - - // 12. finalize -> END - if err := graph.AddEdge(schedulePlanGraphNodeFinalize, compose.END); err != nil { - return nil, err - } - - // 13. exit -> END + // 12. exit -> END if err := graph.AddEdge(schedulePlanGraphNodeExit, compose.END); err != nil { return nil, err } - // 14. 运行步数上限:原有链路 ~10 步 + ReAct 精排(hybridBuild + reactRefine + returnPreview = 3)。 - // 加余量到 25,防止异常分支导致无限循环。 - maxSteps := 25 + // 13. 运行步数上限:plan + preview + hybridBuild + reactRefine + returnPreview = 5 步, + // 加余量到 15,防止异常分支导致无限循环。 + maxSteps := 15 // 15. 编译图得到可执行实例。 runnable, err := graph.Compile(ctx, diff --git a/backend/agent/scheduleplan/nodes.go b/backend/agent/scheduleplan/nodes.go index cf94d5a..183b2bf 100644 --- a/backend/agent/scheduleplan/nodes.go +++ b/backend/agent/scheduleplan/nodes.go @@ -23,31 +23,6 @@ type schedulePlanIntentOutput struct { Strategy string `json:"strategy"` } -// ── materialize 节点模型输出结构 ── - -type schedulePlanMaterializeOutput struct { - Assignments []materializeAssignment `json:"assignments"` - UnassignedItemIDs []int `json:"unassigned_item_ids"` -} - -type materializeAssignment struct { - TaskItemID int `json:"task_item_id"` - Week int `json:"week"` - DayOfWeek int `json:"day_of_week"` - StartSection int `json:"start_section"` - EndSection int `json:"end_section"` - EmbedCourseEventID int `json:"embed_course_event_id"` -} - -// ── reflect 节点模型输出结构 ── - -type schedulePlanReflectOutput struct { - Action string `json:"action"` - Reason string `json:"reason"` - PatchedAssignments []materializeAssignment `json:"patched_assignments"` - RemoveItemIDs []int `json:"remove_item_ids"` -} - // ══════════════════════════════════════════════════════════════ // plan 节点 // ══════════════════════════════════════════════════════════════ @@ -191,292 +166,6 @@ func runPreviewNode( return st, nil } -// ══════════════════════════════════════════════════════════════ -// materialize 节点 -// ══════════════════════════════════════════════════════════════ - -// runMaterializeNode 负责将粗排已分配的任务项转换为可落库结构。 -// -// 职责边界: -// 1) 纯代码转换,不调用模型——粗排算法已完成分配,每个 item 的 EmbeddedTime 已回填; -// 2) 直接将 AllocatedItems 转为 BatchApplyPlans 可消费的 SingleTaskClassItem 数组; -// 3) 跳过 EmbeddedTime 为空的项(未成功分配的任务项),并在回复中说明。 -func runMaterializeNode( - ctx context.Context, - st *SchedulePlanState, - chatModel *ark.ChatModel, - deps SchedulePlanToolDeps, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - if st == nil { - return nil, errors.New("schedule plan graph: nil state in materialize node") - } - if len(st.AllocatedItems) == 0 { - // 无已分配项,preview 已设置了 FinalSummary,直接透传。 - return st, nil - } - - emitStage("schedule_plan.materialize.converting", "正在将排程方案转换为可执行计划...") - - // 1. 将已分配的任务项直接转换为 BatchApplyPlans 请求结构。 - // 粗排算法已在 EmbeddedTime 中回填了 Week/DayOfWeek/SectionFrom/SectionTo, - // 这里只做格式映射,不做二次分配。 - items := make([]model.SingleTaskClassItem, 0, len(st.AllocatedItems)) - skippedCount := 0 - for _, allocated := range st.AllocatedItems { - if allocated.EmbeddedTime == nil { - // EmbeddedTime 为空说明粗排未能为该项找到可用槽位,跳过。 - skippedCount++ - continue - } - items = append(items, model.SingleTaskClassItem{ - TaskItemID: allocated.ID, - Week: allocated.EmbeddedTime.Week, - DayOfWeek: allocated.EmbeddedTime.DayOfWeek, - StartSection: allocated.EmbeddedTime.SectionFrom, - EndSection: allocated.EmbeddedTime.SectionTo, - EmbedCourseEventID: 0, // 阶段 1 暂不支持嵌入水课,后续可扩展 - }) - } - - if len(items) == 0 { - st.FinalSummary = "所有任务项均未能分配到可用时间槽,请检查课表或调整时间范围。" - return st, nil - } - - st.ApplyRequest = &model.UserInsertTaskClassItemToScheduleRequestBatch{ - TaskClassID: st.TaskClassID, - Items: items, - } - - detail := fmt.Sprintf("已生成 %d 项排程安排。", len(items)) - if skippedCount > 0 { - detail += fmt.Sprintf("(%d 项因槽位不足未能安排)", skippedCount) - } - emitStage("schedule_plan.materialize.done", detail) - return st, nil -} - -// ══════════════════════════════════════════════════════════════ -// apply 节点 -// ══════════════════════════════════════════════════════════════ - -// runApplyNode 负责将排程方案落库。 -// -// 职责边界: -// 1) 调用 BatchApplyPlans 服务执行写库; -// 2) 成功时标记 Applied=true; -// 3) 失败时记录错误信息,由分支逻辑决定是否进入 reflect 重试。 -func runApplyNode( - ctx context.Context, - st *SchedulePlanState, - deps SchedulePlanToolDeps, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - if st == nil { - return nil, errors.New("schedule plan graph: nil state in apply node") - } - if st.ApplyRequest == nil || len(st.ApplyRequest.Items) == 0 { - return st, nil - } - - emitStage("schedule_plan.apply.persisting", "正在检查冲突并落库排程方案...") - - err := deps.BatchApplyPlans(ctx, st.TaskClassID, st.UserID, st.ApplyRequest) - if err != nil { - st.RecordApplyError(err.Error()) - if st.CanRetry() { - emitStage("schedule_plan.apply.conflict", fmt.Sprintf("落库失败(第%d次),准备调整方案...", st.RetryCount)) - } else { - emitStage("schedule_plan.apply.failed", "多次尝试后仍无法落库,请手动调整。") - } - return st, nil - } - - st.Applied = true - st.ApplyError = "" - emitStage("schedule_plan.apply.done", "排程方案已成功落库!") - return st, nil -} - -// ══════════════════════════════════════════════════════════════ -// reflect 节点 -// ══════════════════════════════════════════════════════════════ - -// runReflectNode 负责分析落库失败原因并生成修补方案。 -// -// 职责边界: -// 1) 把后端错误信息喂给模型,让模型决定修补策略; -// 2) retry_with_patch:重新构建 ApplyRequest 并回到 apply; -// 3) partial_apply:移除冲突项后重新构建 ApplyRequest; -// 4) give_up:设置 FinalSummary 并退出。 -func runReflectNode( - ctx context.Context, - st *SchedulePlanState, - chatModel *ark.ChatModel, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - if st == nil { - return nil, errors.New("schedule plan graph: nil state in reflect node") - } - - emitStage("schedule_plan.reflect.analyzing", "正在分析失败原因并调整方案...") - - // 1. 构造 prompt,包含错误信息和当前方案。 - currentPlanJSON, _ := json.Marshal(st.ApplyRequest) - prompt := fmt.Sprintf(`排程落库失败,错误信息:%s - -当前排程方案(%d 个任务项): -%s - -请分析失败原因并给出修补方案。`, - st.ApplyError, - len(st.ApplyRequest.Items), - string(currentPlanJSON), - ) - - raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanReflectPrompt, prompt, 1024) - if callErr != nil { - // 模型调用失败,直接放弃。 - st.ReflectAction = "give_up" - st.FinalSummary = fmt.Sprintf("排程落库失败且无法自动修补:%s。请手动调整排程。", st.ApplyError) - return st, nil - } - - parsed, parseErr := parseScheduleJSON[schedulePlanReflectOutput](raw) - if parseErr != nil { - st.ReflectAction = "give_up" - st.FinalSummary = fmt.Sprintf("排程落库失败:%s。请手动调整。", st.ApplyError) - return st, nil - } - - st.ReflectAction = strings.TrimSpace(parsed.Action) - - switch st.ReflectAction { - case "retry_with_patch": - // 2. 用模型给出的修补方案替换当前请求。 - if len(parsed.PatchedAssignments) > 0 { - items := make([]model.SingleTaskClassItem, 0, len(parsed.PatchedAssignments)) - for _, a := range parsed.PatchedAssignments { - items = append(items, model.SingleTaskClassItem{ - TaskItemID: a.TaskItemID, - Week: a.Week, - DayOfWeek: a.DayOfWeek, - StartSection: a.StartSection, - EndSection: a.EndSection, - EmbedCourseEventID: a.EmbedCourseEventID, - }) - } - st.ApplyRequest.Items = items - } - emitStage("schedule_plan.reflect.patched", "已调整方案,准备重新落库。") - - case "partial_apply": - // 3. 移除冲突项后重试。 - if len(parsed.RemoveItemIDs) > 0 { - removeSet := make(map[int]bool) - for _, id := range parsed.RemoveItemIDs { - removeSet[id] = true - } - filtered := make([]model.SingleTaskClassItem, 0) - for _, item := range st.ApplyRequest.Items { - if !removeSet[item.TaskItemID] { - filtered = append(filtered, item) - } - } - st.ApplyRequest.Items = filtered - } - if len(st.ApplyRequest.Items) == 0 { - st.ReflectAction = "give_up" - st.FinalSummary = "移除冲突项后没有剩余可安排的任务,请检查课表或调整时间范围。" - return st, nil - } - emitStage("schedule_plan.reflect.partial", fmt.Sprintf("已移除冲突项,剩余 %d 项准备落库。", len(st.ApplyRequest.Items))) - - default: - // 4. give_up 或未知动作。 - reason := strings.TrimSpace(parsed.Reason) - if reason == "" { - reason = st.ApplyError - } - st.FinalSummary = fmt.Sprintf("排程无法自动完成:%s。建议手动调整。", reason) - } - - return st, nil -} - -// ══════════════════════════════════════════════════════════════ -// finalize 节点 -// ══════════════════════════════════════════════════════════════ - -// runFinalizeNode 负责生成最终回复文案。 -// -// 职责边界: -// 1) 落库成功时调用模型生成友好摘要; -// 2) 落库失败时透传已有的 FinalSummary; -// 3) 将上版方案信息嵌入回复,支持前端在连续对话中回传。 -func runFinalizeNode( - ctx context.Context, - st *SchedulePlanState, - chatModel *ark.ChatModel, - emitStage func(stage, detail string), -) (*SchedulePlanState, error) { - if st == nil { - return nil, errors.New("schedule plan graph: nil state in finalize node") - } - - // 1. 如果已有 FinalSummary(失败场景),直接使用。 - if strings.TrimSpace(st.FinalSummary) != "" { - return st, nil - } - - // 2. 落库未成功时给兜底文案。 - if !st.Applied { - st.FinalSummary = "本次排程未能成功落库,请检查任务类配置后重试。" - return st, nil - } - - emitStage("schedule_plan.finalize.summarizing", "正在生成排程结果摘要...") - - // 3. 调用模型生成友好摘要。 - planJSON, _ := json.Marshal(st.ApplyRequest) - constraintsText := "无" - if len(st.Constraints) > 0 { - constraintsText = strings.Join(st.Constraints, "、") - } - - prompt := fmt.Sprintf(`排程结果: -- 成功安排 %d 个任务项 -- 排程方案:%s -- 用户约束:%s -- 排程意图:%s - -请生成结果摘要。`, - len(st.ApplyRequest.Items), - string(planJSON), - constraintsText, - st.UserIntent, - ) - - raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanFinalizePrompt, prompt, 256) - if callErr != nil { - // 模型生成摘要失败,使用固定文案。 - st.FinalSummary = fmt.Sprintf("排程完成!已成功安排 %d 个任务项。", len(st.ApplyRequest.Items)) - } else { - summary := strings.TrimSpace(raw) - // 移除可能的 JSON 包裹或 markdown。 - summary = strings.Trim(summary, "\"'`") - if summary == "" { - summary = fmt.Sprintf("排程完成!已成功安排 %d 个任务项。", len(st.ApplyRequest.Items)) - } - st.FinalSummary = summary - } - - st.Completed = true - emitStage("schedule_plan.finalize.done", "排程完成!") - return st, nil -} - // ══════════════════════════════════════════════════════════════ // 分支决策函数 // ══════════════════════════════════════════════════════════════ @@ -500,47 +189,6 @@ func selectNextAfterPlan(st *SchedulePlanState) string { return schedulePlanGraphNodePreview } -// selectNextAfterApply 根据 apply 节点结果决定下一步。 -// -// 分支规则: -// 1) Applied=true -> finalize(成功落库) -// 2) CanRetry=true -> reflect(尝试修补) -// 3) CanRetry=false -> finalize(重试耗尽,由 finalize 输出失败文案) -func selectNextAfterApply(st *SchedulePlanState) string { - if st == nil { - return schedulePlanGraphNodeFinalize - } - if st.Applied { - return schedulePlanGraphNodeFinalize - } - if st.CanRetry() { - return schedulePlanGraphNodeReflect - } - // 重试耗尽,设置失败文案后进入 finalize。 - if strings.TrimSpace(st.FinalSummary) == "" { - st.FinalSummary = fmt.Sprintf("排程落库多次失败:%s。请手动调整。", st.ApplyError) - } - return schedulePlanGraphNodeFinalize -} - -// selectNextAfterReflect 根据 reflect 节点结果决定下一步。 -// -// 分支规则: -// 1) give_up -> finalize -// 2) retry_with_patch / partial_apply -> apply(重新落库) -func selectNextAfterReflect(st *SchedulePlanState) string { - if st == nil { - return schedulePlanGraphNodeFinalize - } - if st.ReflectAction == "give_up" { - return schedulePlanGraphNodeFinalize - } - if st.ApplyRequest == nil || len(st.ApplyRequest.Items) == 0 { - return schedulePlanGraphNodeFinalize - } - return schedulePlanGraphNodeApply -} - // ══════════════════════════════════════════════════════════════ // 工具函数 // ══════════════════════════════════════════════════════════════ @@ -610,26 +258,6 @@ func parseScheduleJSON[T any](raw string) (*T, error) { return &out, nil } -// buildTaskItemsInfo 将任务项列表格式化为模型可读的文本。 -func buildTaskItemsInfo(items []model.TaskClassItem) string { - if len(items) == 0 { - return "无任务项" - } - var sb strings.Builder - for i, item := range items { - content := "未命名" - if item.Content != nil && strings.TrimSpace(*item.Content) != "" { - content = strings.TrimSpace(*item.Content) - } - order := i + 1 - if item.Order != nil { - order = *item.Order - } - sb.WriteString(fmt.Sprintf("- ID=%d, 序号=%d, 内容=%s\n", item.ID, order, content)) - } - return sb.String() -} - // extractPreviousPlanFromHistory 从对话历史中提取上版排程方案。 // // 策略: diff --git a/backend/agent/scheduleplan/prompt.go b/backend/agent/scheduleplan/prompt.go index c0947b2..28725dd 100644 --- a/backend/agent/scheduleplan/prompt.go +++ b/backend/agent/scheduleplan/prompt.go @@ -26,93 +26,6 @@ const ( "strategy": "steady" }` - // SchedulePlanMaterializePrompt 用于 materialize 节点: - // 将粗排候选方案与任务项列表匹配,生成可落库的结构。 - // - // 设计要点: - // 1) 模型负责"选择哪些任务项放到哪些时间槽"; - // 2) 后端负责最终校验(冲突检测在 BatchApplyPlans 中执行); - // 3) 输出必须是严格 JSON 数组,每项包含 task_item_id + 时间坐标。 - SchedulePlanMaterializePrompt = `你是 SmartFlow 的排程方案转换器。 -你将收到两组数据: -1) 粗排算法推荐的可用时间槽列表(按周分组)。 -2) 需要安排的任务项列表(每项有 ID 和内容)。 - -你的任务是把每个任务项分配到一个可用时间槽中。 - -约束规则: -1) 每个任务项只能分配到一个时间槽。 -2) 同一个时间槽不能分配多个任务项。 -3) 必须尊重用户约束(如有)。 -4) 如果可用槽位不足,优先安排靠前的任务项,剩余的标记为 unassigned。 - -输出要求: -- 仅输出 JSON,不要 markdown,不要解释。 -- 格式如下: -{ - "assignments": [ - { - "task_item_id": 1, - "week": 1, - "day_of_week": 1, - "start_section": 3, - "end_section": 4, - "embed_course_event_id": 0 - } - ], - "unassigned_item_ids": [5, 6] -}` - - // SchedulePlanReflectPrompt 用于 reflect 节点:分析落库失败原因并生成修补方案。 - // - // 设计要点: - // 1) 模型收到后端错误信息,决定修补策略; - // 2) 可选动作:retry_with_patch(换槽位重试)、partial_apply(跳过冲突项)、give_up(放弃); - // 3) 修补方案必须是结构化 JSON,后端直接消费。 - SchedulePlanReflectPrompt = `你是 SmartFlow 的排程修补分析器。 -排程方案落库失败了,请分析失败原因并给出修补方案。 - -你可以选择以下动作之一: -1) "retry_with_patch":修改冲突项的时间槽后重试。 -2) "partial_apply":跳过冲突项,只落库不冲突的部分。 -3) "give_up":放弃本次排程,向用户解释原因。 - -输出要求: -- 仅输出 JSON,不要 markdown,不要解释。 -- 格式如下: -{ - "action": "retry_with_patch|partial_apply|give_up", - "reason": "简短原因", - "patched_assignments": [ - { - "task_item_id": 1, - "week": 1, - "day_of_week": 2, - "start_section": 5, - "end_section": 6, - "embed_course_event_id": 0 - } - ], - "remove_item_ids": [3] -}` - - // SchedulePlanFinalizePrompt 用于 finalize 节点:生成用户友好的排程结果摘要。 - // - // 设计要点: - // 1) 以事实为主(成功安排了几项、哪些时间段); - // 2) 提及用户约束是否被满足; - // 3) 若有未安排的项目,给出原因和建议。 - SchedulePlanFinalizePrompt = `你是 SmartFlow 的排程结果播报员。 -请根据排程结果,生成一段简洁友好的中文摘要回复给用户。 - -要求: -1) 说明成功安排了多少个任务项。 -2) 简要描述时间分布(如"分布在第1~3周,主要集中在工作日下午")。 -3) 如果有未安排的项目,说明原因。 -4) 如果用户有约束(如"早八不排"),确认是否已遵守。 -5) 语气自然友好,不超过100字。 -6) 不要输出 markdown 或列表格式,只输出纯文本。` - // SchedulePlanReactSystemPrompt 用于 ReAct 精排节点: // LLM 开启深度思考,通过 Tool 调用对粗排结果进行语义化优化。 // diff --git a/backend/agent/scheduleplan/react.go b/backend/agent/scheduleplan/react.go index ad65b01..5746ce6 100644 --- a/backend/agent/scheduleplan/react.go +++ b/backend/agent/scheduleplan/react.go @@ -16,7 +16,7 @@ import ( // reactRoundTimeout 是单轮 ReAct 的超时时间。 // 深度思考模式下 reasoning 阶段可能耗时较长,需要给足时间。 -const reactRoundTimeout = 5 * time.Minute +const reactRoundTimeout = 15 * time.Minute // runReactRefineNode 执行 ReAct 精排循环。 // diff --git a/backend/agent/scheduleplan/runner.go b/backend/agent/scheduleplan/runner.go index 55c09d6..a62c673 100644 --- a/backend/agent/scheduleplan/runner.go +++ b/backend/agent/scheduleplan/runner.go @@ -4,7 +4,6 @@ import ( "context" "github.com/cloudwego/eino-ext/components/model/ark" - "github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/schema" ) @@ -60,27 +59,6 @@ func (r *schedulePlanRunner) previewNode(ctx context.Context, st *SchedulePlanSt return runPreviewNode(ctx, st, r.deps, r.emitStage) } -func (r *schedulePlanRunner) materializeNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - return runMaterializeNode(ctx, st, r.chatModel, r.deps, r.emitStage) -} - -func (r *schedulePlanRunner) applyNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - return runApplyNode(ctx, st, r.deps, r.emitStage) -} - -func (r *schedulePlanRunner) reflectNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - return runReflectNode(ctx, st, r.chatModel, r.emitStage) -} - -func (r *schedulePlanRunner) finalizeNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - return runFinalizeNode(ctx, st, r.chatModel, r.emitStage) -} - -func (r *schedulePlanRunner) exitNode(_ context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { - // exit 节点不做任何业务逻辑,仅把当前状态原样透传到 END。 - return st, nil -} - // ── ReAct 精排节点适配层 ── func (r *schedulePlanRunner) hybridBuildNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { @@ -95,42 +73,27 @@ func (r *schedulePlanRunner) returnPreviewNode(ctx context.Context, st *Schedule return runReturnPreviewNode(ctx, st, r.emitStage) } +func (r *schedulePlanRunner) exitNode(_ context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + // exit 节点不做任何业务逻辑,仅把当前状态原样透传到 END。 + return st, nil +} + // ── 分支决策适配层 ── func (r *schedulePlanRunner) nextAfterPlan(_ context.Context, st *SchedulePlanState) (string, error) { return selectNextAfterPlan(st), nil } -func (r *schedulePlanRunner) nextAfterMaterialize(_ context.Context, st *SchedulePlanState) (string, error) { - // materialize 后:有 ApplyRequest 则去 apply,否则去 exit。 - if st == nil || st.ApplyRequest == nil || len(st.ApplyRequest.Items) == 0 { - return schedulePlanGraphNodeExit, nil - } - return schedulePlanGraphNodeApply, nil -} - -func (r *schedulePlanRunner) nextAfterApply(_ context.Context, st *SchedulePlanState) (string, error) { - return selectNextAfterApply(st), nil -} - -func (r *schedulePlanRunner) nextAfterReflect(_ context.Context, st *SchedulePlanState) (string, error) { - return selectNextAfterReflect(st), nil -} - // nextAfterPreview 根据 preview 结果决定下一步。 // // 分支规则: // 1) preview 失败(无候选方案)-> exit -// 2) HybridScheduleWithPlan 已注入 -> hybridBuild(走 ReAct 精排路径) -// 3) 否则 -> materialize(走原有落库路径,向后兼容) +// 2) 否则 -> hybridBuild(进入 ReAct 精排路径) func (r *schedulePlanRunner) nextAfterPreview(_ context.Context, st *SchedulePlanState) (string, error) { if st == nil || len(st.CandidatePlans) == 0 { return schedulePlanGraphNodeExit, nil } - if r.deps.HybridScheduleWithPlan != nil { - return schedulePlanGraphNodeHybridBuild, nil - } - return schedulePlanGraphNodeMaterialize, nil + return schedulePlanGraphNodeHybridBuild, nil } // nextAfterHybridBuild 根据 hybridBuild 结果决定下一步。 @@ -140,8 +103,3 @@ func (r *schedulePlanRunner) nextAfterHybridBuild(_ context.Context, st *Schedul } return schedulePlanGraphNodeReactRefine, nil } - -// nextAfterFinalize 用于 finalize 分支——固定结束。 -func (r *schedulePlanRunner) nextAfterFinalize(_ context.Context, _ *SchedulePlanState) (string, error) { - return compose.END, nil -} diff --git a/backend/agent/scheduleplan/state.go b/backend/agent/scheduleplan/state.go index 34e3cf6..7a717d5 100644 --- a/backend/agent/scheduleplan/state.go +++ b/backend/agent/scheduleplan/state.go @@ -44,7 +44,7 @@ type SchedulePlanState struct { // CandidatePlans 是粗排算法生成的候选方案(展示型结构,供 SSE 推送给前端预览)。 CandidatePlans []model.UserWeekSchedule - // AllocatedItems 是粗排算法已分配的任务项(EmbeddedTime 已回填),供 materialize 直接转换。 + // AllocatedItems 是粗排算法已分配的任务项(EmbeddedTime 已回填),供 ReAct 精排使用。 AllocatedItems []model.TaskClassItem // ── ReAct 精排阶段 ── @@ -61,27 +61,6 @@ type SchedulePlanState struct { // ReactDone 标记 ReAct 是否已完成。 ReactDone bool - // ── materialize 节点输出 ── - - // ApplyRequest 是转换后的落库请求体。 - ApplyRequest *model.UserInsertTaskClassItemToScheduleRequestBatch - - // ── apply 节点输出 ── - - // Applied 标记是否落库成功。 - Applied bool - // ApplyError 记录落库失败的错误信息,供 reflect 节点分析。 - ApplyError string - - // ── reflect 节点状态 ── - - // RetryCount 记录当前重试次数。 - RetryCount int - // MaxRetry 是最大重试次数(建议 = 2)。 - MaxRetry int - // ReflectAction 记录模型给出的修补动作(retry_with_patch / partial_apply / give_up)。 - ReflectAction string - // ── 连续对话微调 ── // PreviousPlanJSON 是上一版已落库方案的 JSON 序列化,用于增量微调。 @@ -107,23 +86,11 @@ func NewSchedulePlanState(traceID string, userID int, conversationID string) *Sc ConversationID: conversationID, RequestNow: now, RequestNowText: now.In(schedulePlanLocation()).Format(schedulePlanDatetimeLayout), - MaxRetry: 2, Strategy: "steady", ReactMaxRound: 3, } } -// CanRetry 判断当前是否还能继续重试落库。 -func (s *SchedulePlanState) CanRetry() bool { - return s.RetryCount < s.MaxRetry -} - -// RecordApplyError 记录一次落库失败。 -func (s *SchedulePlanState) RecordApplyError(errMsg string) { - s.RetryCount++ - s.ApplyError = errMsg -} - // schedulePlanLocation 返回排程链路使用的业务时区。 func schedulePlanLocation() *time.Location { loc, err := time.LoadLocation(schedulePlanTimezoneName) diff --git a/backend/agent/scheduleplan/tool.go b/backend/agent/scheduleplan/tool.go index aaf47db..94f7ec9 100644 --- a/backend/agent/scheduleplan/tool.go +++ b/backend/agent/scheduleplan/tool.go @@ -18,22 +18,10 @@ type SchedulePlanToolDeps struct { // SmartPlanningRaw 调用粗排算法,同时返回展示结构和已分配的任务项。 // 返回值: // - []UserWeekSchedule:展示型结构,供 SSE 阶段推送给前端预览; - // - []TaskClassItem:已分配的任务项(EmbeddedTime 已回填),供 materialize 直接转换。 + // - []TaskClassItem:已分配的任务项(EmbeddedTime 已回填),供 ReAct 精排使用。 SmartPlanningRaw func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) - // BatchApplyPlans 将排程方案批量落库。 - // 输入:taskClassID、userID、落库请求体。 - // 输出:error(nil 表示全部成功)。 - BatchApplyPlans func(ctx context.Context, taskClassID, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error - - // GetTaskClassByID 获取任务类详情(含关联的 Items)。 - // 用于: - // 1) 校验 task_class_id 合法性; - // 2) 获取 Items 列表,为连续对话微调提供上下文。 - GetTaskClassByID func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) - // HybridScheduleWithPlan 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。 - // 可选依赖:未注入时 ReAct 精排阶段不可用,走原有 materialize 路径。 HybridScheduleWithPlan func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) } @@ -42,11 +30,8 @@ func (d SchedulePlanToolDeps) validate() error { if d.SmartPlanningRaw == nil { return errors.New("schedule plan tool deps: SmartPlanningRaw is nil") } - if d.BatchApplyPlans == nil { - return errors.New("schedule plan tool deps: BatchApplyPlans is nil") - } - if d.GetTaskClassByID == nil { - return errors.New("schedule plan tool deps: GetTaskClassByID is nil") + if d.HybridScheduleWithPlan == nil { + return errors.New("schedule plan tool deps: HybridScheduleWithPlan is nil") } return nil } diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 2069450..c283215 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -98,7 +98,7 @@ func Start() { courseService := service.NewCourseService(courseRepo, scheduleRepo) taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager) scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo) - agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, agentCacheRepo, eventBus, scheduleService, taskClassService) + agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, agentCacheRepo, eventBus, scheduleService) // API 层初始化。 userApi := api.NewUserHandler(userService) diff --git a/backend/service/agent_bridge.go b/backend/service/agent_bridge.go index 75891d3..ebc42d3 100644 --- a/backend/service/agent_bridge.go +++ b/backend/service/agent_bridge.go @@ -1,12 +1,9 @@ package service import ( - "context" - "github.com/LoveLosita/smartflow/backend/dao" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" "github.com/LoveLosita/smartflow/backend/inits" - "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/service/agentsvc" ) @@ -28,7 +25,7 @@ func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskD // NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。 // // 设计目的: -// 1) 通过函数注入避免 agentsvc 包直接依赖 service 层的 ScheduleService / TaskClassService; +// 1) 通过函数注入避免 agentsvc 包直接依赖 service 层的 ScheduleService; // 2) 排程依赖为可选:未注入时排程路由自动回退到普通聊天; // 3) 保持 NewAgentService 签名不变,向下兼容。 func NewAgentServiceWithSchedule( @@ -38,7 +35,6 @@ func NewAgentServiceWithSchedule( agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher, scheduleSvc *ScheduleService, - taskClassSvc *TaskClassService, ) *AgentService { svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher) @@ -47,13 +43,6 @@ func NewAgentServiceWithSchedule( svc.SmartPlanningRawFunc = scheduleSvc.SmartPlanningRaw svc.HybridScheduleWithPlanFunc = scheduleSvc.HybridScheduleWithPlan } - if taskClassSvc != nil { - svc.BatchApplyPlansFunc = taskClassSvc.BatchApplyPlans - // GetTaskClassByID 复用 TaskClassService 内部的 DAO 调用。 - svc.GetTaskClassByIDFunc = func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) { - return taskClassSvc.GetCompleteTaskClassByID(ctx, taskClassID, userID) - } - } return svc } diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index 4b01f23..356dd4b 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -32,14 +32,8 @@ type AgentService struct { // SmartPlanningRawFunc 调用粗排算法,同时返回展示结构和已分配的任务项。 // 由 service/agent_bridge.go 在构造时注入 ScheduleService.SmartPlanningRaw。 SmartPlanningRawFunc func(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) - // BatchApplyPlansFunc 将排程方案批量落库。 - // 由 service/agent_bridge.go 在构造时注入 TaskClassService.BatchApplyPlans。 - BatchApplyPlansFunc func(ctx context.Context, taskClassID, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error - // GetTaskClassByIDFunc 获取任务类详情(含 Items)。 - // 由 service/agent_bridge.go 在构造时注入。 - GetTaskClassByIDFunc func(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) // HybridScheduleWithPlanFunc 构建混合日程(既有日程 + 粗排建议),供 ReAct 精排使用。 - // 由 service/agent_bridge.go 在构造时注入。可选:未注入时走原有 materialize 路径。 + // 由 service/agent_bridge.go 在构造时注入。 HybridScheduleWithPlanFunc func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) } diff --git a/backend/service/agentsvc/agent_schedule_plan.go b/backend/service/agentsvc/agent_schedule_plan.go index c148ac8..d3b9957 100644 --- a/backend/service/agentsvc/agent_schedule_plan.go +++ b/backend/service/agentsvc/agent_schedule_plan.go @@ -33,7 +33,7 @@ func (s *AgentService) runSchedulePlanFlow( modelName string, ) (string, error) { // 1. 依赖预检:排程依赖函数必须注入,否则无法完成排程链路。 - if s.SmartPlanningRawFunc == nil || s.BatchApplyPlansFunc == nil || s.GetTaskClassByIDFunc == nil { + if s.SmartPlanningRawFunc == nil || s.HybridScheduleWithPlanFunc == nil { return "", errors.New("schedule plan service dependencies are not ready") } if selectedModel == nil { @@ -71,8 +71,6 @@ func (s *AgentService) runSchedulePlanFlow( State: state, Deps: scheduleplan.SchedulePlanToolDeps{ SmartPlanningRaw: s.SmartPlanningRawFunc, - BatchApplyPlans: s.BatchApplyPlansFunc, - GetTaskClassByID: s.GetTaskClassByIDFunc, HybridScheduleWithPlan: s.HybridScheduleWithPlanFunc, }, UserMessage: userMessage, diff --git a/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md b/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md index 555cefa..9ecacd1 100644 --- a/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md +++ b/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md @@ -62,10 +62,95 @@ ### 6.1 新流程(graph 结构) ``` plan → preview(粗排) → hybridBuild(混合日程) → reactRefine(ReAct循环) → returnPreview → END - ↑ | - └────────────────────────┘ (tool失败重试,最多N轮) ``` -当 `HybridScheduleWithPlan` 依赖未注入时,preview 后自动走原有 materialize → apply 路径。 +智能排程仅返回预览结果,不自动落库。用户确认后由前端调用独立落库接口完成持久化。 + +#### 6.1.1 整体 Graph 流程图 + +> 下图展示完整的 SchedulePlanGraph 编排结构(ReAct 精排路径)。 + +```mermaid +flowchart TD + START([START]) --> plan["plan
意图识别 + 约束提取
callScheduleModelForJSON"] + + plan --> plan_br{{"FinalSummary 非空
或 TaskClassID ≤ 0 ?"}} + plan_br -- 是 --> exit_a["exit → END
提前终止"] + plan_br -- 否 --> preview["preview
调用粗排算法
SmartPlanningRaw"] + + preview --> pv_br{{"preview 结果?"}} + pv_br -- "失败 / 无候选" --> exit_b["exit → END"] + pv_br -- "成功" --> hybridBuild["hybridBuild
构建混合日程
existing + suggested"] + + hybridBuild --> hb_br{{"HybridEntries 非空?"}} + hb_br -- 空 --> exit_c["exit → END"] + hb_br -- 非空 --> reactRefine["reactRefine
ReAct 精排循环
最多 3 轮 × 5min/轮"] + reactRefine --> returnPreview["returnPreview
HybridEntries → 预览格式
不落库,等用户确认"] + returnPreview --> END_A([END]) + + %% ═══ 样式 ═══ + style reactRefine fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + style hybridBuild fill:#e8f5e9,stroke:#2e7d32 + style returnPreview fill:#e8f5e9,stroke:#2e7d32 +``` + +#### 6.1.2 ReAct 精排内部循环 + +> 下图展开 `reactRefine` 节点内部的 ReAct 循环逻辑(`react.go`)。 +> 每轮独立设置 5 分钟超时(`reactRoundTimeout`),reasoning_content 实时推送到 SSE。 + +```mermaid +flowchart TD + enter([进入 reactRefine]) --> init["构造初始 messages
system: ReAct 优化 prompt
user: 混合日程 JSON + 约束"] + + init --> round_gate{{"ReactRound < ReactMaxRound (3) ?"}} + + round_gate -- 否 --> max_round["标记完成
'已达最大轮次,使用当前结果'"] + max_round --> to_return([退出 → returnPreview]) + + round_gate -- 是 --> inc["ReactRound++
创建 roundCtx
context.WithTimeout(ctx, 5min)"] + + inc --> stream["chatModel.Stream(roundCtx, messages)
ThinkingTypeEnabled"] + + stream --> recv_loop["循环 reader.Recv()"] + recv_loop --> has_reasoning{{"有 reasoning_content ?"}} + has_reasoning -- 是 --> push_sse["推送到 outChan
前端实时可见思考过程"] + push_sse --> has_content + has_reasoning -- 否 --> has_content{{"有 content ?"}} + has_content -- 是 --> acc["累积到 contentBuilder"] + acc --> recv_more{{"EOF ?"}} + has_content -- 否 --> recv_more + recv_more -- 否 --> recv_loop + recv_more -- 是 --> parse + + stream -- "超时 / 错误" --> timeout["ReactDone = true
'模型调用超时或失败,
使用粗排结果'"] + timeout --> to_return + + parse["解析 LLM JSON 输出
parseReactLLMOutput"] + parse --> parse_br{{"解析结果?"}} + + parse_br -- 解析失败 --> parse_fail["ReactDone = true
'LLM 输出格式异常'"] + parse_fail --> to_return + + parse_br -- "done: true" --> done["ReactDone = true
ReactSummary = summary"] + done --> to_return + + parse_br -- "无 tool_calls
且 done ≠ true" --> auto_done["ReactDone = true
'排程优化已完成'"] + auto_done --> to_return + + parse_br -- "有 tool_calls" --> dispatch["依次分发 Tool 调用
dispatchReactTool"] + + dispatch --> tools["执行工具(纯内存操作)
Swap ─ 交换两个 suggested 时间
Move ─ 移动到新时间段
TimeAvailable ─ 查询是否空闲
GetAvailableSlots ─ 列出可用槽"] + + tools --> append["messages += assistant 输出
messages += tool 结果(user msg)"] + append --> round_gate + + %% ═══ 样式 ═══ + style stream fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + style dispatch fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + style tools fill:#e8f5e9,stroke:#2e7d32 + style push_sse fill:#fce4ec,stroke:#c62828 + style timeout fill:#ffebee,stroke:#b71c1c +``` ### 6.2 混合日程(HybridScheduleEntry) 将既有日程(existing)和粗排建议(suggested)统一到同一结构: