diff --git a/.gitignore b/.gitignore index 75f51a7..13f1aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ backend/config.yaml .vscode/ .DS_Store # Mac 用户必加 .gocache/ -.gomodcache/ \ No newline at end of file +.gomodcache/ +.claude/ +.omc/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 91c7c92..739c699 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ 1. 默认语言规则:所有注释、接口文案、说明、评审反馈均使用中文。 2. 请勤加注释,尤其是复杂逻辑部分,确保代码易于理解和维护。 3. 每次在本地执行测试命令(如 `go test`)后,必须清理项目根目录下的 `.gocache` 目录,避免缓存文件长期堆积。 +4. 文件编码统一使用 UTF-8(无 BOM),禁止使用 GBK、GB2312 等其他编码,避免中文内容出现乱码。 ## 注释规范(强制) diff --git a/backend/agent/route/route.go b/backend/agent/route/route.go index 2d708f8..099fbed 100644 --- a/backend/agent/route/route.go +++ b/backend/agent/route/route.go @@ -30,28 +30,31 @@ var ( // 支持动作: // 1. quick_note_create:新增随口记任务; // 2. task_query:查询任务; - // 3. chat:普通聊天; - // 4. quick_note:历史兼容别名,解析后会映射到 quick_note_create。 - routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|quick_note|chat)["']?[^>]*>`) + // 3. schedule_plan:智能排程(生成/微调排程计划); + // 4. chat:普通聊天; + // 5. quick_note:历史兼容别名,解析后会映射到 quick_note_create。 + routeHeaderRegex = regexp.MustCompile(`(?is)<\s*smartflow_route\b[^>]*\bnonce\s*=\s*["']?([a-zA-Z0-9\-]+)["']?[^>]*\baction\s*=\s*["']?(quick_note_create|task_query|schedule_plan|quick_note|chat)["']?[^>]*>`) // routeReasonRegex 用于提取可选的理由块,方便日志排障。 routeReasonRegex = regexp.MustCompile(`(?is)<\s*smartflow_reason\s*>(.*?)<\s*/\s*smartflow_reason\s*>`) ) const routeControlPrompt = `你是 SmartFlow 的请求分流控制器。 -你的唯一任务是给后端返回“可机读控制码”,不要做用户可见回复,不要解释。 +你的唯一任务是给后端返回”可机读控制码”,不要做用户可见回复,不要解释。 动作定义: -1) quick_note_create:用户明确希望“记录/安排/提醒某件未来要做的事”。 -2) task_query:用户想“查看/筛选/排序/获取”已有任务(如最紧急、按DDL、某象限、关键词)。 -3) chat:其余全部普通对话(包括闲聊、知识问答、纯讨论“怎么安排任务”但未要求你真的去操作)。 +1) quick_note_create:用户明确希望”记录/安排/提醒某件未来要做的事”。 +2) task_query:用户想”查看/筛选/排序/获取”已有任务(如最紧急、按DDL、某象限、关键词)。 +3) schedule_plan:用户想”生成/调整/微调日程排程”(如”帮我排个学习计划”、”把早八的课调走”、”我不想周末学习”)。 +4) chat:其余全部普通对话(包括闲聊、知识问答、纯讨论”怎么安排任务”但未要求你真的去操作)。 判定优先级(冲突时按顺序): -1) 若句子核心诉求是“帮我记一件事”,选 quick_note_create。 -2) 若核心诉求是“帮我查任务列表/某类任务”,选 task_query。 -3) 其他情况选 chat。 +1) 若句子核心诉求是”帮我记一件事”,选 quick_note_create。 +2) 若核心诉求是”帮我查任务列表/某类任务”,选 task_query。 +3) 若核心诉求是”帮我排日程/调整日程/生成学习计划/修改排程”,选 schedule_plan。 +4) 其他情况选 chat。 输出格式必须严格如下(两行): - + 一句不超过30字的中文理由 禁止输出任何其他内容。` @@ -63,6 +66,7 @@ const ( ActionChat Action = "chat" ActionQuickNoteCreate Action = "quick_note_create" ActionTaskQuery Action = "task_query" + ActionSchedulePlan Action = "schedule_plan" // ActionQuickNote 是历史兼容别名,只用于解析旧 action 值。 ActionQuickNote Action = "quick_note" @@ -132,6 +136,16 @@ func DecideActionRouting(ctx context.Context, selectedModel *ark.ChatModel, user TrustRoute: true, Detail: reason, } + case ActionSchedulePlan: + reason := strings.TrimSpace(decision.Reason) + if reason == "" { + reason = "识别到排程请求,准备执行智能排程流程。" + } + return RoutingDecision{ + Action: ActionSchedulePlan, + TrustRoute: true, + Detail: reason, + } case ActionChat: return RoutingDecision{ Action: ActionChat, @@ -226,7 +240,7 @@ func ParseRouteControlTag(raw, expectedNonce string) (*ControlDecision, error) { actionText := strings.ToLower(strings.TrimSpace(header[2])) action := Action(actionText) switch action { - case ActionQuickNoteCreate, ActionTaskQuery, ActionChat: + case ActionQuickNoteCreate, ActionTaskQuery, ActionSchedulePlan, ActionChat: // 合法动作直接通过。 case ActionQuickNote: // 兼容旧动作值:统一映射到 quick_note_create。 diff --git a/backend/agent/scheduleplan/graph.go b/backend/agent/scheduleplan/graph.go new file mode 100644 index 0000000..94a2841 --- /dev/null +++ b/backend/agent/scheduleplan/graph.go @@ -0,0 +1,243 @@ +package scheduleplan + +import ( + "context" + "errors" + + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" +) + +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 精排前置) + schedulePlanGraphNodeHybridBuild = "schedule_plan_hybrid_build" + // 图节点:ReAct 精排循环 + schedulePlanGraphNodeReactRefine = "schedule_plan_react_refine" + // 图节点:返回精排预览结果(不落库) + schedulePlanGraphNodeReturnPreview = "schedule_plan_return_preview" +) + +// SchedulePlanGraphRunInput 是运行"智能排程 graph"所需的输入依赖。 +// +// 说明: +// 1) EmitStage 可选,用于把节点进度推送给外层(例如 SSE 状态块); +// 2) Extra 传递前端附加参数(如 task_class_id); +// 3) ChatHistory 用于连续对话微调场景。 +type SchedulePlanGraphRunInput struct { + Model *ark.ChatModel + State *SchedulePlanState + Deps SchedulePlanToolDeps + UserMessage string + Extra map[string]any + ChatHistory []*schema.Message + EmitStage func(stage, detail string) + // ── ReAct 精排所需 ── + OutChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content + ModelName string // 模型名称,用于构造 OpenAI 兼容 chunk +} + +// RunSchedulePlanGraph 执行"智能排程"图编排。 +// +// 图结构: +// +// START -> plan -> [branch] -> preview -> [branch] -> materialize -> [branch] -> apply -> [branch] +// | | | | +// exit exit exit finalize (成功) +// | +// reflect -> [branch] -> apply (重试) +// | +// finalize (放弃) +// +// 该文件只负责"连线与分支",节点内部逻辑全部下沉到 nodes.go。 +func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) (*SchedulePlanState, error) { + // 1. 启动前硬校验:模型、状态、依赖缺一不可。 + if input.Model == nil { + return nil, errors.New("schedule plan graph: model is nil") + } + if input.State == nil { + return nil, errors.New("schedule plan graph: state is nil") + } + if err := input.Deps.validate(); err != nil { + return nil, err + } + + // 2. 统一封装阶段推送函数,避免各节点反复判空。 + emitStage := func(stage, detail string) { + if input.EmitStage != nil { + input.EmitStage(stage, detail) + } + } + + // 3. 构造 runner,收口节点依赖。 + runner := newSchedulePlanRunner( + input.Model, + input.Deps, + emitStage, + input.UserMessage, + input.Extra, + input.ChatHistory, + input.OutChan, + input.ModelName, + ) + + // 4. 创建状态图容器:输入/输出类型都为 *SchedulePlanState。 + graph := compose.NewGraph[*SchedulePlanState, *SchedulePlanState]() + + // 5. 注册节点。 + if err := graph.AddLambdaNode(schedulePlanGraphNodePlan, compose.InvokableLambda(runner.planNode)); err != nil { + return nil, err + } + 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 + } + if err := graph.AddLambdaNode(schedulePlanGraphNodeHybridBuild, compose.InvokableLambda(runner.hybridBuildNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(schedulePlanGraphNodeReactRefine, compose.InvokableLambda(runner.reactRefineNode)); err != nil { + return nil, err + } + if err := graph.AddLambdaNode(schedulePlanGraphNodeReturnPreview, compose.InvokableLambda(runner.returnPreviewNode)); err != nil { + return nil, err + } + + // ── 连线 ── + + // 6. START -> plan + if err := graph.AddEdge(compose.START, schedulePlanGraphNodePlan); err != nil { + return nil, err + } + + // 7. plan -> [branch] -> preview | exit + if err := graph.AddBranch(schedulePlanGraphNodePlan, compose.NewGraphBranch( + runner.nextAfterPlan, + map[string]bool{ + schedulePlanGraphNodePreview: true, + schedulePlanGraphNodeExit: true, + }, + )); err != nil { + return nil, err + } + + // 8. preview -> [branch] -> hybridBuild | materialize | 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 + if err := graph.AddBranch(schedulePlanGraphNodeHybridBuild, compose.NewGraphBranch( + runner.nextAfterHybridBuild, + map[string]bool{ + schedulePlanGraphNodeReactRefine: true, + schedulePlanGraphNodeExit: true, + }, + )); err != nil { + return nil, err + } + + // 8.2 reactRefine -> returnPreview(固定边) + if err := graph.AddEdge(schedulePlanGraphNodeReactRefine, schedulePlanGraphNodeReturnPreview); err != nil { + return nil, err + } + + // 8.3 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 + if err := graph.AddEdge(schedulePlanGraphNodeExit, compose.END); err != nil { + return nil, err + } + + // 14. 运行步数上限:原有链路 ~10 步 + ReAct 精排(hybridBuild + reactRefine + returnPreview = 3)。 + // 加余量到 25,防止异常分支导致无限循环。 + maxSteps := 25 + + // 15. 编译图得到可执行实例。 + runnable, err := graph.Compile(ctx, + compose.WithGraphName("SchedulePlanGraph"), + compose.WithMaxRunSteps(maxSteps), + compose.WithNodeTriggerMode(compose.AnyPredecessor), + ) + if err != nil { + return nil, err + } + + // 16. 执行图并返回最终状态。 + return runnable.Invoke(ctx, input.State) +} diff --git a/backend/agent/scheduleplan/nodes.go b/backend/agent/scheduleplan/nodes.go new file mode 100644 index 0000000..cf94d5a --- /dev/null +++ b/backend/agent/scheduleplan/nodes.go @@ -0,0 +1,817 @@ +package scheduleplan + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/cloudwego/eino-ext/components/model/ark" + einoModel "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// ── plan 节点模型输出结构 ── + +type schedulePlanIntentOutput struct { + Intent string `json:"intent"` + Constraints []string `json:"constraints"` + TaskClassID int `json:"task_class_id"` + 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 节点 +// ══════════════════════════════════════════════════════════════ + +// runPlanNode 负责"意图识别 + 约束提取"。 +// +// 职责边界: +// 1) 从用户消息中提取排程意图、约束条件、策略; +// 2) task_class_id 优先从 Extra 字段获取,模型推断作为兜底; +// 3) 不负责调用粗排算法,只做意图分析。 +func runPlanNode( + ctx context.Context, + st *SchedulePlanState, + chatModel *ark.ChatModel, + userMessage string, + extra map[string]any, + chatHistory []*schema.Message, + emitStage func(stage, detail string), +) (*SchedulePlanState, error) { + if st == nil { + return nil, errors.New("schedule plan graph: nil state in plan node") + } + + emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求...") + + // 1. 优先从 Extra 字段获取 task_class_id,避免依赖模型推断。 + if extra != nil { + if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 { + st.TaskClassID = tcID + } + } + + // 2. 检查对话历史中是否包含上版排程方案(用于连续对话微调)。 + previousPlan := extractPreviousPlanFromHistory(chatHistory) + if previousPlan != "" { + st.PreviousPlanJSON = previousPlan + st.IsAdjustment = true + } + + // 3. 构造 prompt 让模型分析意图和约束。 + adjustmentHint := "" + if st.IsAdjustment { + adjustmentHint = "\n注意:这是对已有排程的微调请求。用户可能只想调整部分内容(如'早八不想学习'),请只提取变更部分的约束。" + } + + prompt := fmt.Sprintf(`当前时间(北京时间):%s +用户输入:%s%s + +请分析用户的排程意图并提取约束条件。`, + st.RequestNowText, + strings.TrimSpace(userMessage), + adjustmentHint, + ) + + // 3.1 模型调用失败时保守处理:只要有 task_class_id 就继续,否则报错。 + raw, callErr := callScheduleModelForJSON(ctx, chatModel, SchedulePlanIntentPrompt, prompt, 256) + if callErr != nil { + if st.TaskClassID > 0 { + // 有 task_class_id 就可以继续,意图用兜底值。 + st.UserIntent = strings.TrimSpace(userMessage) + emitStage("schedule_plan.plan.fallback", "意图分析失败,使用默认配置继续。") + return st, nil + } + st.FinalSummary = "抱歉,我没能理解你的排程需求,请再描述一下或直接传入任务类 ID。" + return st, nil + } + + // 3.2 解析模型输出。 + parsed, parseErr := parseScheduleJSON[schedulePlanIntentOutput](raw) + if parseErr != nil { + if st.TaskClassID > 0 { + st.UserIntent = strings.TrimSpace(userMessage) + return st, nil + } + st.FinalSummary = "抱歉,我没能解析排程意图,请再试一次。" + return st, nil + } + + // 4. 回填状态。 + st.UserIntent = strings.TrimSpace(parsed.Intent) + if st.UserIntent == "" { + st.UserIntent = strings.TrimSpace(userMessage) + } + if len(parsed.Constraints) > 0 { + st.Constraints = parsed.Constraints + } + if st.TaskClassID <= 0 && parsed.TaskClassID > 0 { + st.TaskClassID = parsed.TaskClassID + } + if parsed.Strategy == "rapid" { + st.Strategy = "rapid" + } + + emitStage("schedule_plan.plan.done", fmt.Sprintf("已理解排程意图:%s", st.UserIntent)) + return st, nil +} + +// ══════════════════════════════════════════════════════════════ +// preview 节点 +// ══════════════════════════════════════════════════════════════ + +// runPreviewNode 负责调用粗排算法生成候选方案。 +// +// 职责边界: +// 1) 调用 SmartPlanningRaw 服务,同时获取展示结构和已分配的任务项; +// 2) 展示结构供 SSE 阶段推送给前端预览; +// 3) 已分配的任务项供 materialize 节点直接转换为落库请求,无需模型介入。 +func runPreviewNode( + 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 preview node") + } + + // 1. 校验 task_class_id 必须有效。 + if st.TaskClassID <= 0 { + st.FinalSummary = "缺少任务类 ID,无法生成排程方案。请在请求中传入 task_class_id。" + return st, nil + } + + emitStage("schedule_plan.preview.generating", "正在调用排程算法生成候选方案...") + + // 2. 调用粗排服务,同时拿到展示结构和已分配的任务项。 + displayPlans, allocatedItems, err := deps.SmartPlanningRaw(ctx, st.UserID, st.TaskClassID) + if err != nil { + st.FinalSummary = fmt.Sprintf("排程算法执行失败:%s。请检查任务类配置是否正确。", err.Error()) + return st, nil + } + + if len(allocatedItems) == 0 { + st.FinalSummary = "排程算法未找到可用时间槽,可能是课表已排满或任务类时间范围内无空闲。" + return st, nil + } + + st.CandidatePlans = displayPlans + st.AllocatedItems = allocatedItems + emitStage("schedule_plan.preview.done", fmt.Sprintf("已生成候选方案,共 %d 个任务项已分配。", len(allocatedItems))) + 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 +} + +// ══════════════════════════════════════════════════════════════ +// 分支决策函数 +// ══════════════════════════════════════════════════════════════ + +// selectNextAfterPlan 根据 plan 节点结果决定下一步。 +// +// 分支规则: +// 1) FinalSummary 非空 -> exit(plan 阶段已确定无法继续) +// 2) TaskClassID 无效 -> exit +// 3) 其余 -> preview +func selectNextAfterPlan(st *SchedulePlanState) string { + if st == nil { + return schedulePlanGraphNodeExit + } + if strings.TrimSpace(st.FinalSummary) != "" { + return schedulePlanGraphNodeExit + } + if st.TaskClassID <= 0 { + return schedulePlanGraphNodeExit + } + 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 +} + +// ══════════════════════════════════════════════════════════════ +// 工具函数 +// ══════════════════════════════════════════════════════════════ + +// callScheduleModelForJSON 调用模型并期望返回 JSON 结果。 +func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) { + if chatModel == nil { + return "", errors.New("schedule plan: model is nil") + } + 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 +} + +// parseScheduleJSON 解析模型返回的 JSON 内容。 +// 兼容 ```json ... ``` 包裹和额外文本。 +func parseScheduleJSON[T any](raw string) (*T, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, errors.New("empty response") + } + + // 兼容 ```json ... ``` 包裹。 + 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 + } + + // 提取最外层 JSON 对象。 + start := strings.Index(clean, "{") + end := strings.LastIndex(clean, "}") + if start == -1 || end == -1 || end <= start { + return nil, fmt.Errorf("no json object found in: %s", clean) + } + obj := clean[start : end+1] + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, err + } + 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 从对话历史中提取上版排程方案。 +// +// 策略: +// 在助手消息中查找包含"排程完成"标记的最近一条,提取其中的方案信息。 +// 当前版本使用简单的文本匹配,后续可升级为结构化存储。 +func extractPreviousPlanFromHistory(history []*schema.Message) string { + if len(history) == 0 { + return "" + } + // 从后往前遍历,找最近的排程成功消息。 + for i := len(history) - 1; i >= 0; i-- { + msg := history[i] + if msg == nil || msg.Role != schema.Assistant { + continue + } + content := strings.TrimSpace(msg.Content) + if strings.Contains(content, "排程完成") || strings.Contains(content, "已成功安排") { + return content + } + } + return "" +} + +// ══════════════════════════════════════════════════════════════ +// hybridBuild 节点 +// ══════════════════════════════════════════════════════════════ + +// runHybridBuildNode 负责构建"混合日程":将既有日程与粗排建议合并。 +// +// 职责边界: +// 1) 调用 HybridScheduleWithPlan 服务方法; +// 2) 将结果写入 State.HybridEntries,供 ReAct 精排节点操作; +// 3) 同时保留 AllocatedItems,供后续可能的落库使用。 +func runHybridBuildNode( + 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 hybridBuild node") + } + if deps.HybridScheduleWithPlan == nil { + return nil, errors.New("schedule plan graph: HybridScheduleWithPlan dependency not injected") + } + if st.TaskClassID <= 0 { + st.FinalSummary = "缺少任务类 ID,无法构建混合日程。" + return st, nil + } + + emitStage("schedule_plan.hybrid.building", "正在构建混合日程...") + + entries, allocatedItems, err := deps.HybridScheduleWithPlan(ctx, st.UserID, st.TaskClassID) + if err != nil { + st.FinalSummary = fmt.Sprintf("构建混合日程失败:%s", err.Error()) + return st, nil + } + if len(entries) == 0 { + st.FinalSummary = "混合日程为空,无可优化内容。" + return st, nil + } + + st.HybridEntries = entries + st.AllocatedItems = allocatedItems + + suggestedCount := 0 + for _, e := range entries { + if e.Status == "suggested" { + suggestedCount++ + } + } + emitStage("schedule_plan.hybrid.done", fmt.Sprintf("混合日程已构建,共 %d 个条目(%d 个可优化)。", len(entries), suggestedCount)) + return st, nil +} + +// ══════════════════════════════════════════════════════════════ +// returnPreview 节点 +// ══════════════════════════════════════════════════════════════ + +// runReturnPreviewNode 负责将 ReAct 优化后的混合日程转为前端预览格式。 +// +// 职责边界: +// 1) 从 HybridEntries 中提取最终排程结果; +// 2) 转换为 []UserWeekSchedule 格式(复用 sectionTimeMap); +// 3) 设置 FinalSummary 为 ReAct 的优化摘要; +// 4) 不落库——用户需确认后再走落库链路。 +func runReturnPreviewNode( + ctx context.Context, + st *SchedulePlanState, + emitStage func(stage, detail string), +) (*SchedulePlanState, error) { + if st == nil { + return nil, errors.New("schedule plan graph: nil state in returnPreview node") + } + + emitStage("schedule_plan.preview_return.building", "正在生成优化后的排程预览...") + + // 1. 将 HybridEntries 中 suggested 的任务回写到 AllocatedItems 的 EmbeddedTime。 + // 这样后续如果用户确认,可以直接走 materialize → apply 落库。 + suggestedMap := make(map[int]*model.HybridScheduleEntry) + for i := range st.HybridEntries { + e := &st.HybridEntries[i] + if e.Status == "suggested" && e.TaskItemID > 0 { + suggestedMap[e.TaskItemID] = e + } + } + for i := range st.AllocatedItems { + item := &st.AllocatedItems[i] + if entry, ok := suggestedMap[item.ID]; ok && item.EmbeddedTime != nil { + item.EmbeddedTime.Week = entry.Week + item.EmbeddedTime.DayOfWeek = entry.DayOfWeek + item.EmbeddedTime.SectionFrom = entry.SectionFrom + item.EmbeddedTime.SectionTo = entry.SectionTo + } + } + + // 2. 将 HybridEntries 转为 CandidatePlans([]UserWeekSchedule)供前端展示。 + st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries) + + // 3. 设置最终摘要。 + if strings.TrimSpace(st.ReactSummary) != "" { + st.FinalSummary = st.ReactSummary + } else { + st.FinalSummary = fmt.Sprintf("排程优化完成,共 %d 个任务已安排。请确认后落库。", len(suggestedMap)) + } + st.Completed = true + + emitStage("schedule_plan.preview_return.done", "排程预览已生成,等待确认。") + return st, nil +} + +// hybridEntriesToWeekSchedules 将混合日程条目转为前端展示格式。 +func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule { + // sectionTimeMap 与 conv/schedule.go 保持一致。 + sectionTimeMap := map[int][2]string{ + 1: {"08:00", "08:45"}, 2: {"08:55", "09:40"}, + 3: {"10:15", "11:00"}, 4: {"11:10", "11:55"}, + 5: {"14:00", "14:45"}, 6: {"14:55", "15:40"}, + 7: {"16:15", "17:00"}, 8: {"17:10", "17:55"}, + 9: {"19:00", "19:45"}, 10: {"19:55", "20:40"}, + 11: {"20:50", "21:35"}, 12: {"21:45", "22:30"}, + } + + // 按周分组 + weekMap := make(map[int][]model.WeeklyEventBrief) + for _, e := range entries { + startTime := "" + endTime := "" + if t, ok := sectionTimeMap[e.SectionFrom]; ok { + startTime = t[0] + } + if t, ok := sectionTimeMap[e.SectionTo]; ok { + endTime = t[1] + } + + brief := model.WeeklyEventBrief{ + DayOfWeek: e.DayOfWeek, + Name: e.Name, + StartTime: startTime, + EndTime: endTime, + Type: e.Type, + Span: e.SectionTo - e.SectionFrom + 1, + Status: e.Status, + } + if e.EventID > 0 { + brief.ID = e.EventID + } + weekMap[e.Week] = append(weekMap[e.Week], brief) + } + + // 排序输出 + result := make([]model.UserWeekSchedule, 0, len(weekMap)) + for w, events := range weekMap { + result = append(result, model.UserWeekSchedule{Week: w, Events: events}) + } + // 按周次排序 + for i := 0; i < len(result); i++ { + for j := i + 1; j < len(result); j++ { + if result[j].Week < result[i].Week { + result[i], result[j] = result[j], result[i] + } + } + } + return result +} diff --git a/backend/agent/scheduleplan/prompt.go b/backend/agent/scheduleplan/prompt.go new file mode 100644 index 0000000..c0947b2 --- /dev/null +++ b/backend/agent/scheduleplan/prompt.go @@ -0,0 +1,175 @@ +package scheduleplan + +const ( + // SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。 + // + // 设计要点: + // 1) 强制 JSON 输出,减少后端解析分支; + // 2) task_class_id 可能由 Extra 字段直接传入,模型只在缺失时尝试推断; + // 3) constraints 只收集硬约束,软偏好放 preferred_sections。 + SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。 +请根据用户输入,提取排程意图与约束条件。 + +必须完成以下任务: +1) 用一句话概括用户的排程意图(intent)。 +2) 提取所有硬约束(constraints),如"早八不排"、"周末休息"等。 +3) 如果用户明确提到了任务类名称或ID,输出 task_class_id(整数);否则输出 -1。 +4) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。 + +输出要求: +- 仅输出 JSON,不要 markdown,不要解释。 +- 格式如下: +{ + "intent": "用户排程意图摘要", + "constraints": ["约束1", "约束2"], + "task_class_id": -1, + "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 调用对粗排结果进行语义化优化。 + // + // 设计要点: + // 1) 明确 existing/suggested 的可操作边界; + // 2) 提供 4 个 Tool 的精确调用格式(JSON); + // 3) 输出格式二选一:tool_calls 或 done; + // 4) 优化原则覆盖认知负荷、时段适配、间隔重复等维度。 + SchedulePlanReactSystemPrompt = `你是 SmartFlow 智能排程精排优化器。 + +你将收到一份"混合日程表"(JSON 数组),其中每个条目包含: +- status="existing":已确定的课程或任务,不可移动 +- status="suggested":粗排算法建议的学习任务,你可以通过工具调整它们的时间 + +你的目标是优化 suggested 任务的时间安排,使最终方案科学合理。 + +## 优化原则 + +1. 上下文切换成本:相同或相近科目的任务尽量安排在相邻时段,减少频繁切换带来的认知损耗 +2. 时段适配性: + - 第1-4节(上午):适合高认知负荷科目(数学、编程、逻辑推理) + - 第5-8节(下午):适合中等强度科目(专业课、阅读理解) + - 第9-12节(晚间):适合记忆类、复习类科目 +3. 学习效率曲线:避免连续安排超过4节高强度学习,适当穿插不同类型的任务 +4. 间隔重复:同一科目的复习任务在时间上适当分散到不同天,符合遗忘曲线规律 +5. 用户约束:严格遵守用户提出的约束条件(如有) + +## 可用工具 + +1. Swap — 交换两个 suggested 任务的时间位置 + 参数:task_a(task_item_id),task_b(task_item_id) + +2. Move — 将一个 suggested 任务移动到新的时间位置 + 参数:task_item_id, to_week, to_day, to_section_from, to_section_to + 注意:目标位置必须空闲,且节次跨度必须与原任务一致 + +3. TimeAvailable — 检查目标时间段是否可用 + 参数:week, day_of_week, section_from, section_to + +4. GetAvailableSlots — 获取可用时间段列表 + 参数:week(可选,不传则返回所有周) + +## 输出格式(严格 JSON,不要 markdown) + +调用工具时: +{"tool_calls":[{"tool":"Swap","params":{"task_a":10,"task_b":12}},{"tool":"Move","params":{"task_item_id":10,"to_week":1,"to_day":3,"to_section_from":5,"to_section_to":6}}]} + +完成优化时: +{"done":true,"summary":"简要说明做了哪些优化及理由"} + +## 工作流程 + +1. 仔细分析当前排程,识别不合理之处 +2. 如需了解可用时间,先调用 GetAvailableSlots +3. 确定调整方案后,调用 Swap 或 Move 执行 +4. 你可以一次输出多个工具调用,后端会按顺序执行 +5. 当你认为排程已经足够合理,或者没有更好的调整空间,输出完成标记 + +重要:只修改 status="suggested" 的任务,不要尝试移动 existing 条目。` +) diff --git a/backend/agent/scheduleplan/react.go b/backend/agent/scheduleplan/react.go new file mode 100644 index 0000000..ad65b01 --- /dev/null +++ b/backend/agent/scheduleplan/react.go @@ -0,0 +1,209 @@ +package scheduleplan + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/agent/chat" + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/schema" + arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// reactRoundTimeout 是单轮 ReAct 的超时时间。 +// 深度思考模式下 reasoning 阶段可能耗时较长,需要给足时间。 +const reactRoundTimeout = 5 * time.Minute + +// runReactRefineNode 执行 ReAct 精排循环。 +// +// 核心流程(最多 ReactMaxRound 轮): +// 1. 构造 messages(system prompt + 混合日程 JSON + 上轮 tool 结果) +// 2. 调用 chatModel.Stream() + ThinkingTypeEnabled +// 3. reasoning_content 实时推送到 outChan(前端可见思考过程) +// 4. content 累积后解析:done=true 则退出,tool_calls 则执行 +// 5. tool 结果拼入下一轮 messages +func runReactRefineNode( + ctx context.Context, + st *SchedulePlanState, + chatModel *ark.ChatModel, + outChan chan<- string, + modelName string, + emitStage func(stage, detail string), +) (*SchedulePlanState, error) { + if st == nil { + return nil, fmt.Errorf("schedule plan graph: nil state in reactRefine node") + } + if chatModel == nil { + return nil, fmt.Errorf("schedule plan graph: model is nil in reactRefine node") + } + if len(st.HybridEntries) == 0 { + st.ReactDone = true + st.ReactSummary = "无可优化的排程条目。" + return st, nil + } + + // 准备 SSE 流式输出的基础参数 + if strings.TrimSpace(modelName) == "" { + modelName = "smartflow-worker" + } + + // 构造混合日程 JSON(只在首轮构造,后续轮次复用) + hybridJSON, err := json.Marshal(st.HybridEntries) + if err != nil { + return nil, fmt.Errorf("序列化混合日程失败: %w", err) + } + + // 用户约束文本 + constraintsText := "无" + if len(st.Constraints) > 0 { + constraintsText = strings.Join(st.Constraints, "、") + } + + // 对话历史:跨轮次累积 + messages := []*schema.Message{ + schema.SystemMessage(SchedulePlanReactSystemPrompt), + schema.UserMessage(fmt.Sprintf( + "以下是当前混合日程(JSON):\n%s\n\n用户约束:%s\n\n请分析并优化 suggested 任务的时间安排。", + string(hybridJSON), constraintsText, + )), + } + + // ── ReAct 主循环 ── + for st.ReactRound < st.ReactMaxRound { + st.ReactRound++ + emitStage("schedule_plan.react.round", fmt.Sprintf("第 %d 轮优化思考...", st.ReactRound)) + + // 1. 带超时的 context + roundCtx, cancel := context.WithTimeout(ctx, reactRoundTimeout) + + // 2. 调用模型(流式 + 深度思考) + content, streamErr := streamReactRound(roundCtx, chatModel, modelName, messages, outChan) + cancel() + + if streamErr != nil { + emitStage("schedule_plan.react.error", fmt.Sprintf("第 %d 轮模型调用失败: %s", st.ReactRound, streamErr.Error())) + // 明确标记为失败,不伪装成功 + st.ReactDone = true + st.ReactSummary = fmt.Sprintf("排程优化未完成:第 %d 轮模型调用超时或失败,使用粗排结果。", st.ReactRound) + break + } + + // 3. 解析 LLM 输出 + parsed, parseErr := parseReactLLMOutput(content) + if parseErr != nil { + // 解析失败,把原始输出当作摘要,结束循环 + emitStage("schedule_plan.react.parse_error", "LLM 输出格式异常,结束优化。") + st.ReactSummary = "排程优化已完成(LLM 输出格式异常,使用当前结果)。" + st.ReactDone = true + break + } + + // 4. 检查是否完成 + if parsed.Done { + st.ReactSummary = parsed.Summary + st.ReactDone = true + emitStage("schedule_plan.react.done", "优化完成。") + break + } + + // 5. 执行 tool calls + if len(parsed.ToolCalls) == 0 { + // 没有 tool 调用也没有 done,视为完成 + st.ReactSummary = "排程优化已完成。" + st.ReactDone = true + break + } + + results := make([]reactToolResult, 0, len(parsed.ToolCalls)) + for _, call := range parsed.ToolCalls { + var result reactToolResult + st.HybridEntries, result = dispatchReactTool(st.HybridEntries, call) + results = append(results, result) + statusMark := "OK" + if !result.Success { + statusMark = "FAIL" + } + emitStage("schedule_plan.react.tool_call", + fmt.Sprintf("[%s] %s: %s", statusMark, result.Tool, result.Result)) + } + + // 6. 将 tool 结果拼入下一轮 messages + // 先追加 assistant 的输出 + messages = append(messages, schema.AssistantMessage(content, nil)) + // 再追加 tool 结果作为 user message + resultsJSON, _ := json.Marshal(results) + messages = append(messages, schema.UserMessage( + fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化,或输出 {\"done\":true,\"summary\":\"...\"} 完成。", string(resultsJSON)), + )) + } + + // 循环结束兜底 + if !st.ReactDone { + st.ReactDone = true + if strings.TrimSpace(st.ReactSummary) == "" { + st.ReactSummary = fmt.Sprintf("排程优化已达最大轮次(%d 轮),使用当前结果。", st.ReactRound) + } + emitStage("schedule_plan.react.max_round", "已达最大优化轮次,使用当前结果。") + } + + return st, nil +} + +// streamReactRound 执行单轮 ReAct 模型调用: +// - 流式推送 reasoning_content 到 outChan(前端可见思考过程) +// - 累积 content 并返回(包含 tool_calls 或 done 信号) +func streamReactRound( + ctx context.Context, + chatModel *ark.ChatModel, + modelName string, + messages []*schema.Message, + outChan chan<- string, +) (string, error) { + // 开启深度思考 + reader, err := chatModel.Stream(ctx, messages, + ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}), + ) + if err != nil { + return "", fmt.Errorf("模型 Stream 调用失败: %w", err) + } + defer reader.Close() + + requestID := "react-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + created := time.Now().Unix() + var contentBuilder strings.Builder + + for { + chunk, recvErr := reader.Recv() + if recvErr == io.EOF { + break + } + if recvErr != nil { + return contentBuilder.String(), fmt.Errorf("流式接收失败: %w", recvErr) + } + if chunk == nil { + continue + } + + // 推送 reasoning_content 到前端(实时思考过程) + if chunk.ReasoningContent != "" && outChan != nil { + payload, fmtErr := chat.ToOpenAIStream( + &schema.Message{ReasoningContent: chunk.ReasoningContent}, + requestID, modelName, created, false, + ) + if fmtErr == nil && payload != "" { + outChan <- payload + } + } + + // 累积 content(tool_calls 或 done 信号) + if chunk.Content != "" { + contentBuilder.WriteString(chunk.Content) + } + } + + return strings.TrimSpace(contentBuilder.String()), nil +} diff --git a/backend/agent/scheduleplan/runner.go b/backend/agent/scheduleplan/runner.go new file mode 100644 index 0000000..55c09d6 --- /dev/null +++ b/backend/agent/scheduleplan/runner.go @@ -0,0 +1,147 @@ +package scheduleplan + +import ( + "context" + + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" +) + +// schedulePlanRunner 是"单次图运行"的请求级依赖容器。 +// +// 设计目标: +// 1) 把节点运行所需依赖(model/deps/emit/extra/history)就近收口; +// 2) 让 graph.go 只保留"节点连线"和"方法引用",提升可读性; +// 3) 避免在 graph.go 里重复出现内联闭包和参数透传。 +type schedulePlanRunner struct { + chatModel *ark.ChatModel + deps SchedulePlanToolDeps + emitStage func(stage, detail string) + userMessage string + extra map[string]any + chatHistory []*schema.Message + // ── ReAct 精排所需 ── + outChan chan<- string // SSE 流式输出通道,用于推送 reasoning_content + modelName string // 模型名称,用于构造 OpenAI 兼容 chunk +} + +// newSchedulePlanRunner 构造请求级 runner。 +// 生命周期仅限一次 graph invoke,不做跨请求复用。 +func newSchedulePlanRunner( + chatModel *ark.ChatModel, + deps SchedulePlanToolDeps, + emitStage func(stage, detail string), + userMessage string, + extra map[string]any, + chatHistory []*schema.Message, + outChan chan<- string, + modelName string, +) *schedulePlanRunner { + return &schedulePlanRunner{ + chatModel: chatModel, + deps: deps, + emitStage: emitStage, + userMessage: userMessage, + extra: extra, + chatHistory: chatHistory, + outChan: outChan, + modelName: modelName, + } +} + +// ── 节点方法引用适配层 ── + +func (r *schedulePlanRunner) planNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runPlanNode(ctx, st, r.chatModel, r.userMessage, r.extra, r.chatHistory, r.emitStage) +} + +func (r *schedulePlanRunner) previewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + 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) { + return runHybridBuildNode(ctx, st, r.deps, r.emitStage) +} + +func (r *schedulePlanRunner) reactRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runReactRefineNode(ctx, st, r.chatModel, r.outChan, r.modelName, r.emitStage) +} + +func (r *schedulePlanRunner) returnPreviewNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runReturnPreviewNode(ctx, st, r.emitStage) +} + +// ── 分支决策适配层 ── + +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(走原有落库路径,向后兼容) +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 +} + +// nextAfterHybridBuild 根据 hybridBuild 结果决定下一步。 +func (r *schedulePlanRunner) nextAfterHybridBuild(_ context.Context, st *SchedulePlanState) (string, error) { + if st == nil || len(st.HybridEntries) == 0 { + return schedulePlanGraphNodeExit, nil + } + 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 new file mode 100644 index 0000000..34e3cf6 --- /dev/null +++ b/backend/agent/scheduleplan/state.go @@ -0,0 +1,139 @@ +package scheduleplan + +import ( + "time" + + "github.com/LoveLosita/smartflow/backend/model" +) + +const ( + // schedulePlanTimezoneName 是排程链路默认业务时区。 + // 与随口记保持一致,固定东八区,避免容器运行在 UTC 导致"明天/今晚"偏移。 + schedulePlanTimezoneName = "Asia/Shanghai" + + // schedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。 + schedulePlanDatetimeLayout = "2006-01-02 15:04" +) + +// SchedulePlanState 是"智能排程"链路在 graph 节点间传递的统一状态容器。 +// +// 设计目标: +// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散���; +// 2) 支持"粗排 -> 校验 -> 修补重试 -> 落库"的完整链路追踪; +// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。 +type SchedulePlanState struct { + // ── 基础上下文 ── + TraceID string + UserID int + ConversationID string + RequestNow time.Time + RequestNowText string + + // ── plan 节点输出 ── + + // UserIntent 是模型对用户排程意图的结构化摘要(如"帮我安排高数复习计划")。 + UserIntent string + // Constraints 是用户提出的硬约束列表(如 ["早八不排", "周末休息"])。 + Constraints []string + // TaskClassID 是目标任务类 ID,由 Extra 字段或模型抽取获得。 + TaskClassID int + // Strategy 是排程策略(steady/rapid),默认 steady。 + Strategy string + + // ── preview 节点输出 ── + + // CandidatePlans 是粗排算法生成的候选方案(展示型结构,供 SSE 推送给前端预览)。 + CandidatePlans []model.UserWeekSchedule + // AllocatedItems 是粗排算法已分配的任务项(EmbeddedTime 已回填),供 materialize 直接转换。 + AllocatedItems []model.TaskClassItem + + // ── ReAct 精排阶段 ── + + // HybridEntries 是混合日程条目列表,包含既有日程(existing)和粗排建议(suggested)。 + // ReAct 工具直接在此切片上操作(内存修改,不涉及 DB)。 + HybridEntries []model.HybridScheduleEntry + // ReactRound 当前 ReAct 循环轮次。 + ReactRound int + // ReactMaxRound 最大循环轮次(建议 3)。 + ReactMaxRound int + // ReactSummary LLM 输出的优化摘要。 + ReactSummary string + // 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 序列化,用于增量微调。 + // 从对话历史中提取,不做持久化。 + PreviousPlanJSON string + // IsAdjustment 标记本次是否为微调请求(而非全新排程)。 + IsAdjustment bool + + // ── 最终输出 ── + + // FinalSummary 是 graph 最终给用户的回复文案。 + FinalSummary string + // Completed 标记整个排程链路是否成功完成。 + Completed bool +} + +// NewSchedulePlanState 创建排程状态对象并初始化默认值。 +func NewSchedulePlanState(traceID string, userID int, conversationID string) *SchedulePlanState { + now := schedulePlanNowToMinute() + return &SchedulePlanState{ + TraceID: traceID, + UserID: userID, + 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) + if err != nil { + return time.Local + } + return loc +} + +// schedulePlanNowToMinute 返回当前时间并截断到分钟级。 +func schedulePlanNowToMinute() time.Time { + return time.Now().In(schedulePlanLocation()).Truncate(time.Minute) +} diff --git a/backend/agent/scheduleplan/tool.go b/backend/agent/scheduleplan/tool.go new file mode 100644 index 0000000..aaf47db --- /dev/null +++ b/backend/agent/scheduleplan/tool.go @@ -0,0 +1,76 @@ +package scheduleplan + +import ( + "context" + "errors" + "strconv" + + "github.com/LoveLosita/smartflow/backend/model" +) + +// SchedulePlanToolDeps 描述"智能排程工具包"需要的外部依赖。 +// +// 设计目标: +// 1) 通过函数注入把 agent 包与 service/dao 解耦,避免循环依赖; +// 2) 每个函数对应一个可独立 mock 的业务能力; +// 3) 后续可按需扩展(如局部修补、任务类自动生成等)。 +type SchedulePlanToolDeps struct { + // SmartPlanningRaw 调用粗排算法,同时返回展示结构和已分配的任务项。 + // 返回值: + // - []UserWeekSchedule:展示型结构,供 SSE 阶段推送给前端预览; + // - []TaskClassItem:已分配的任务项(EmbeddedTime 已回填),供 materialize 直接转换。 + 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) +} + +// validate 校验依赖完整性,缺失任意一个都无法完成排程链路。 +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") + } + return nil +} + +// ExtraInt 从 extra map 中安全提取整数值。 +// +// 兼容策略: +// 1) JSON 数字默认解析为 float64,做 int 转换; +// 2) 兼容字符串形式(如 "42"),用 Atoi 解析; +// 3) 其余类型返回 false,由调用方决定后续处理。 +func ExtraInt(extra map[string]any, key string) (int, bool) { + v, ok := extra[key] + if !ok { + return 0, false + } + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case string: + i, err := strconv.Atoi(n) + return i, err == nil + default: + return 0, false + } +} diff --git a/backend/agent/scheduleplan/tools_react.go b/backend/agent/scheduleplan/tools_react.go new file mode 100644 index 0000000..607e9bc --- /dev/null +++ b/backend/agent/scheduleplan/tools_react.go @@ -0,0 +1,339 @@ +package scheduleplan + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/LoveLosita/smartflow/backend/model" +) + +// ── ReAct Tool 调用/结果结构 ── + +// reactToolCall 是 LLM 输出的单个工具调用。 +type reactToolCall struct { + Tool string `json:"tool"` + Params map[string]any `json:"params"` +} + +// reactToolResult 是单个工具调用的执行结果。 +type reactToolResult struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Result string `json:"result"` +} + +// reactLLMOutput 是 LLM 输出的完整 JSON 结构。 +type reactLLMOutput struct { + Done bool `json:"done"` + Summary string `json:"summary"` + ToolCalls []reactToolCall `json:"tool_calls"` +} + +// ── 工具分发器 ── + +// dispatchReactTool 根据工具名分发调用,返回(可能修改后的)entries 和执行结果。 +func dispatchReactTool(entries []model.HybridScheduleEntry, call reactToolCall) ([]model.HybridScheduleEntry, reactToolResult) { + switch call.Tool { + case "Swap": + return reactToolSwap(entries, call.Params) + case "Move": + return reactToolMove(entries, call.Params) + case "TimeAvailable": + return entries, reactToolTimeAvailable(entries, call.Params) + case "GetAvailableSlots": + return entries, reactToolGetAvailableSlots(entries, call.Params) + default: + return entries, reactToolResult{Tool: call.Tool, Success: false, Result: fmt.Sprintf("未知工具: %s", call.Tool)} + } +} + +// ── 参数提取辅助 ── + +func paramInt(params map[string]any, key string) (int, bool) { + v, ok := params[key] + if !ok { + return 0, false + } + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + default: + return 0, false + } +} + +// findSuggestedByID 在 entries 中查找指定 TaskItemID 的 suggested 条目索引。 +func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int { + for i, e := range entries { + if e.TaskItemID == taskItemID && e.Status == "suggested" { + return i + } + } + return -1 +} + +// sectionsOverlap 判断两个节次区间是否有交集。 +func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool { + return aFrom <= bTo && bFrom <= aTo +} + +// hasConflict 检查目标时间段是否与 entries 中任何条目冲突(排除 excludeIdx)。 +func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st, excludeIdx int) (bool, string) { + for i, e := range entries { + if i == excludeIdx { + continue + } + if e.Week == week && e.DayOfWeek == day && sectionsOverlap(e.SectionFrom, e.SectionTo, sf, st) { + return true, fmt.Sprintf("%s(%s)", e.Name, e.Type) + } + } + return false, "" +} + +// ══════════════════════════════════════════════════════════════ +// Tool 1: Swap — 交换两个 suggested 任务的时间 +// ══════════════════════════════════════════════════════════════ + +func reactToolSwap(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) { + idA, okA := paramInt(params, "task_a") + idB, okB := paramInt(params, "task_b") + if !okA || !okB { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失:需要 task_a 和 task_b(task_item_id)"} + } + if idA == idB { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 和 task_b 不能相同"} + } + + idxA := findSuggestedByID(entries, idA) + idxB := findSuggestedByID(entries, idB) + if idxA == -1 { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idA)} + } + if idxB == -1 { + return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", idB)} + } + + // 交换时间坐标 + a, b := &entries[idxA], &entries[idxB] + a.Week, b.Week = b.Week, a.Week + a.DayOfWeek, b.DayOfWeek = b.DayOfWeek, a.DayOfWeek + a.SectionFrom, b.SectionFrom = b.SectionFrom, a.SectionFrom + a.SectionTo, b.SectionTo = b.SectionTo, a.SectionTo + + return entries, reactToolResult{ + Tool: "Swap", Success: true, + Result: fmt.Sprintf("已交换 [%s](id=%d) 和 [%s](id=%d) 的时间", a.Name, idA, b.Name, idB), + } +} + +// ══════════════════════════════════════════════════════════════ +// Tool 2: Move — 将一个 suggested 任务移动到新时间 +// ══════════════════════════════════════════════════════════════ + +func reactToolMove(entries []model.HybridScheduleEntry, params map[string]any) ([]model.HybridScheduleEntry, reactToolResult) { + taskID, ok := paramInt(params, "task_item_id") + if !ok { + return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 task_item_id"} + } + toWeek, ok1 := paramInt(params, "to_week") + toDay, ok2 := paramInt(params, "to_day") + toSF, ok3 := paramInt(params, "to_section_from") + toST, ok4 := paramInt(params, "to_section_to") + if !ok1 || !ok2 || !ok3 || !ok4 { + return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week, to_day, to_section_from, to_section_to"} + } + + // 基础校验 + if toDay < 1 || toDay > 7 { + return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 不合法,应为 1-7", toDay)} + } + if toSF < 1 || toST > 12 || toSF > toST { + return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次范围 %d-%d 不合法,应为 1-12 且 from<=to", toSF, toST)} + } + + idx := findSuggestedByID(entries, taskID) + if idx == -1 { + return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("找不到 task_item_id=%d 的 suggested 任务", taskID)} + } + + // 节次跨度必须一致 + origSpan := entries[idx].SectionTo - entries[idx].SectionFrom + newSpan := toST - toSF + if origSpan != newSpan { + return entries, reactToolResult{Tool: "Move", Success: false, + Result: fmt.Sprintf("节次跨度不一致:原任务占 %d 节,目标占 %d 节", origSpan+1, newSpan+1)} + } + + // 冲突检测(排除自身) + if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, idx); conflict { + return entries, reactToolResult{Tool: "Move", Success: false, + Result: fmt.Sprintf("目标时间 W%dD%d 第%d-%d节 已被 %s 占用", toWeek, toDay, toSF, toST, name)} + } + + // 执行移动 + e := &entries[idx] + oldDesc := fmt.Sprintf("W%dD%d 第%d-%d节", e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo) + e.Week, e.DayOfWeek, e.SectionFrom, e.SectionTo = toWeek, toDay, toSF, toST + newDesc := fmt.Sprintf("W%dD%d 第%d-%d节", toWeek, toDay, toSF, toST) + + return entries, reactToolResult{ + Tool: "Move", Success: true, + Result: fmt.Sprintf("已将 [%s](id=%d) 从 %s 移动到 %s", e.Name, taskID, oldDesc, newDesc), + } +} + +// ══════════════════════════════════════════════════════════════ +// Tool 3: TimeAvailable — 检查目标时间段是否可用 +// ══════════════════════════════════════════════════════════════ + +func reactToolTimeAvailable(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult { + week, ok1 := paramInt(params, "week") + day, ok2 := paramInt(params, "day_of_week") + sf, ok3 := paramInt(params, "section_from") + st, ok4 := paramInt(params, "section_to") + if !ok1 || !ok2 || !ok3 || !ok4 { + return reactToolResult{Tool: "TimeAvailable", Success: false, Result: "参数缺失:需要 week, day_of_week, section_from, section_to"} + } + + if conflict, name := hasConflict(entries, week, day, sf, st, -1); conflict { + return reactToolResult{Tool: "TimeAvailable", Success: true, + Result: fmt.Sprintf(`{"available":false,"conflict_with":"%s"}`, name)} + } + return reactToolResult{Tool: "TimeAvailable", Success: true, Result: `{"available":true}`} +} + +// ══════════════════════════════════════════════════════════════ +// Tool 4: GetAvailableSlots — 返回可用时间段列表 +// ══════════════════════════════════════════════════════════════ + +func reactToolGetAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any) reactToolResult { + filterWeek, _ := paramInt(params, "week") // 0 表示不过滤 + + // 1. 收集所有周次范围 + minW, maxW := 999, 0 + for _, e := range entries { + if e.Week < minW { + minW = e.Week + } + if e.Week > maxW { + maxW = e.Week + } + } + if minW > maxW { + return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: "[]"} + } + + // 2. 构建占用集合 + type slotKey struct{ W, D, S int } + occupied := make(map[slotKey]bool) + for _, e := range entries { + for s := e.SectionFrom; s <= e.SectionTo; s++ { + occupied[slotKey{e.Week, e.DayOfWeek, s}] = true + } + } + + // 3. 遍历所有时间格,找出空闲并合并连续节次 + type availSlot struct { + Week, Day, From, To int + } + var slots []availSlot + + startW, endW := minW, maxW + if filterWeek > 0 { + startW, endW = filterWeek, filterWeek + } + + for w := startW; w <= endW; w++ { + for d := 1; d <= 7; d++ { + runStart := 0 + for s := 1; s <= 12; s++ { + if !occupied[slotKey{w, d, s}] { + if runStart == 0 { + runStart = s + } + } else { + if runStart > 0 { + slots = append(slots, availSlot{w, d, runStart, s - 1}) + runStart = 0 + } + } + } + if runStart > 0 { + slots = append(slots, availSlot{w, d, runStart, 12}) + } + } + } + + // 4. 按自然顺序排序(已经是了,但确保) + sort.Slice(slots, func(i, j int) bool { + if slots[i].Week != slots[j].Week { + return slots[i].Week < slots[j].Week + } + if slots[i].Day != slots[j].Day { + return slots[i].Day < slots[j].Day + } + return slots[i].From < slots[j].From + }) + + // 5. 序列化 + type slotJSON struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + } + out := make([]slotJSON, 0, len(slots)) + for _, s := range slots { + out = append(out, slotJSON{s.Week, s.Day, s.From, s.To}) + } + + data, _ := json.Marshal(out) + return reactToolResult{Tool: "GetAvailableSlots", Success: true, Result: string(data)} +} + +// ── 辅助:解析 LLM 输出 ── + +// parseReactLLMOutput 解析 LLM 的 JSON 输出。 +// 兼容 ```json ... ``` 包裹。 +func parseReactLLMOutput(raw string) (*reactLLMOutput, error) { + clean := strings.TrimSpace(raw) + if clean == "" { + return nil, fmt.Errorf("LLM 输出为空") + } + // 兼容 markdown 包裹 + if strings.HasPrefix(clean, "```") { + clean = strings.TrimPrefix(clean, "```json") + clean = strings.TrimPrefix(clean, "```") + clean = strings.TrimSuffix(clean, "```") + clean = strings.TrimSpace(clean) + } + + var out reactLLMOutput + if err := json.Unmarshal([]byte(clean), &out); err == nil { + return &out, nil + } + + // 提取最外层 JSON 对象 + start := strings.Index(clean, "{") + end := strings.LastIndex(clean, "}") + if start == -1 || end == -1 || end <= start { + return nil, fmt.Errorf("无法从 LLM 输出中提取 JSON: %s", truncate(clean, 200)) + } + obj := clean[start : end+1] + if err := json.Unmarshal([]byte(obj), &out); err != nil { + return nil, fmt.Errorf("JSON 解析失败: %w", err) + } + return &out, nil +} + +// truncate 截断字符串到指定长度。 +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/backend/api/agent.go b/backend/api/agent.go index 531900b..3d33fc8 100644 --- a/backend/api/agent.go +++ b/backend/api/agent.go @@ -57,7 +57,7 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) { c.Writer.Header().Set("X-Conversation-ID", conversationID) userID := c.GetInt("user_id") - outChan, errChan := api.svc.AgentChat(c.Request.Context(), req.Message, req.Thinking, req.Model, userID, conversationID) + outChan, errChan := api.svc.AgentChat(c.Request.Context(), req.Message, req.Thinking, req.Model, userID, conversationID, req.Extra) // 4) 转发 SSE 流 c.Stream(func(w io.Writer) bool { diff --git a/backend/api/course.go b/backend/api/course.go index 7666e07..0a8b95c 100644 --- a/backend/api/course.go +++ b/backend/api/course.go @@ -54,7 +54,7 @@ func (sa *CourseHandler) AddUserCourses(c *gin.Context) { userIDInterface := c.GetInt("user_id") //3.调用 service 层的 AddUserCoursesIntoSchedule 方法添加课程 // 创建一个带 1 秒超时的上下文 - ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() // 记得释放资源 conflicts, err := sa.service.AddUserCourses(ctx, req, userIDInterface) if err != nil { diff --git a/backend/cmd/start.go b/backend/cmd/start.go index ecfda46..2069450 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.NewAgentService(aiHub, agentRepo, taskRepo, agentCacheRepo, eventBus) + agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, agentCacheRepo, eventBus, scheduleService, taskClassService) // API 层初始化。 userApi := api.NewUserHandler(userService) diff --git a/backend/logic/smart_planning.go b/backend/logic/smart_planning.go index 7b2c7da..0e30c5c 100644 --- a/backend/logic/smart_planning.go +++ b/backend/logic/smart_planning.go @@ -174,10 +174,20 @@ func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskCla if err != nil { return nil, err } - //3.把这些时间通过DTO函数回填到涉及周的 UserWeekSchedule 结构中,供前端展示 + //3.把这些时间通过DTO函数回填到涉��周的 UserWeekSchedule 结构中,供前端展示 return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil } +// SmartPlanningRawItems 执行粗排算法并直接返回已分配的任务项列表。 +// +// 与 SmartPlanningMainLogic 共享完全相同的构建网格和分配逻辑, +// 但不做展示格式转换,直接返回 allocatedItems(每项的 EmbeddedTime 已回填)。 +// 供 Agent 排程链路使用,避免从展示结构反向解析导致信息丢失。 +func SmartPlanningRawItems(schedules []model.Schedule, taskClass *model.TaskClass) ([]model.TaskClassItem, error) { + g := buildTimeGrid(schedules, taskClass) + return computeAllocation(g, taskClass.Items, *taskClass.Strategy) +} + // buildTimeGrid 构建一个时间格子,标记出哪些时间段被占用、哪些被屏蔽、哪些是水课 func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid { diff --git a/backend/model/agent.go b/backend/model/agent.go index 523cada..3c7cc45 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -3,10 +3,11 @@ package model import "time" type UserSendMessageRequest struct { - ConversationID string `json:"conversation_id,omitempty"` - Message string `json:"message" binding:"required"` - Model string `json:"model,omitempty"` - Thinking bool `json:"thinking,omitempty"` + ConversationID string `json:"conversation_id,omitempty"` + Message string `json:"message" binding:"required"` + Model string `json:"model,omitempty"` + Thinking bool `json:"thinking,omitempty"` + Extra map[string]any `json:"extra,omitempty"` // 附加参数(如 task_class_id),供 agent 分支链路使用 } // ChatHistoryPersistPayload 是“聊天消息持久化请求”业务 DTO。 diff --git a/backend/model/schedule.go b/backend/model/schedule.go index 19f9556..17fe6fc 100644 --- a/backend/model/schedule.go +++ b/backend/model/schedule.go @@ -118,6 +118,27 @@ type OngoingSchedule struct { EndTime time.Time `json:"end_time"` } +// HybridScheduleEntry 表示"混合日程"中的一个时间块。 +// +// 设计目标: +// 将既有日程(课程/已落库任务)与粗排建议的任务统一到同一结构中, +// 供 ReAct 精排引擎在内存中操作。 +// +// Status 语义: +// - "existing":已确定的日程,LLM 不可移动; +// - "suggested":粗排建议的任务,LLM 可通过 Tool 调整时间。 +type HybridScheduleEntry struct { + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SectionFrom int `json:"section_from"` + SectionTo int `json:"section_to"` + Name string `json:"name"` + Type string `json:"type"` // "course" | "task" + Status string `json:"status"` // "existing" | "suggested" + TaskItemID int `json:"task_item_id,omitempty"` // 仅 suggested 的 task 有值 + EventID int `json:"event_id,omitempty"` // 仅 existing 有值 +} + func (ScheduleEvent) TableName() string { return "schedule_events" } func (Schedule) TableName() string { return "schedules" } diff --git a/backend/service/agent_bridge.go b/backend/service/agent_bridge.go index bdbff6a..75891d3 100644 --- a/backend/service/agent_bridge.go +++ b/backend/service/agent_bridge.go @@ -1,9 +1,12 @@ 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" ) @@ -14,9 +17,43 @@ import ( type AgentService = agentsvc.AgentService // NewAgentService 是迁移期兼容构造函数。 +// // 说明: -// 1) 外部调用签名保持不变; +// 1) 外部调用签名不变,新增排程依赖通过可选方式注入(见 NewAgentServiceWithSchedule); // 2) 真实构造逻辑已下沉到 service/agentsvc 包。 func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService { return agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher) } + +// NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。 +// +// 设计目的: +// 1) 通过函数注入避免 agentsvc 包直接依赖 service 层的 ScheduleService / TaskClassService; +// 2) 排程依赖为可选:未注入时排程路由自动回退到普通聊天; +// 3) 保持 NewAgentService 签名不变,向下兼容。 +func NewAgentServiceWithSchedule( + aiHub *inits.AIHub, + repo *dao.AgentDAO, + taskRepo *dao.TaskDAO, + agentRedis *dao.AgentCache, + eventPublisher outboxinfra.EventPublisher, + scheduleSvc *ScheduleService, + taskClassSvc *TaskClassService, +) *AgentService { + svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher) + + // 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。 + if scheduleSvc != nil { + 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 5e9de2f..4b01f23 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -26,6 +26,21 @@ type AgentService struct { taskRepo *dao.TaskDAO agentCache *dao.AgentCache eventPublisher outboxinfra.EventPublisher + + // ── 排程计划依赖(函数注入,避免 service 包循环依赖)── + + // 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 路径。 + HybridScheduleWithPlanFunc func(ctx context.Context, userID, taskClassID int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) } // NewAgentService 构造 AgentService。 @@ -233,7 +248,7 @@ func (s *AgentService) runNormalChatFlow( s.ensureConversationTitleAsync(userID, chatID) } -func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string) (<-chan string, <-chan error) { +func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThinking bool, modelName string, userID int, chatID string, extra map[string]any) (<-chan string, <-chan error) { requestStart := time.Now() traceID := uuid.NewString() @@ -366,7 +381,27 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin return } - // 3.6 未知 action 兜底:走普通聊天,保证可用性。 + // 3.6 schedule_plan:执行智能排程 graph。 + if routing.Action == route.ActionSchedulePlan { + reply, planErr := s.runSchedulePlanFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, extra, progress.Emit, outChan, resolvedModelName) + if planErr != nil { + log.Printf("智能排程 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, planErr) + progress.Emit("schedule_plan.fallback", "智能排程暂不可用,先切回普通对话。") + s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) + return + } + + if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil { + pushErrNonBlocking(errChan, emitErr) + return + } + requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens + s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, 0, requestTotalTokens, errChan) + s.ensureConversationTitleAsync(userID, chatID) + return + } + + // 3.7 未知 action 兜底:走普通聊天,保证可用性。 s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan) }() diff --git a/backend/service/agentsvc/agent_schedule_plan.go b/backend/service/agentsvc/agent_schedule_plan.go new file mode 100644 index 0000000..c148ac8 --- /dev/null +++ b/backend/service/agentsvc/agent_schedule_plan.go @@ -0,0 +1,101 @@ +package agentsvc + +import ( + "context" + "errors" + "log" + "strings" + + "github.com/LoveLosita/smartflow/backend/agent/scheduleplan" + "github.com/LoveLosita/smartflow/backend/conv" + "github.com/LoveLosita/smartflow/backend/pkg" + "github.com/cloudwego/eino-ext/components/model/ark" + "github.com/cloudwego/eino/schema" +) + +// runSchedulePlanFlow 执行"智能排程"分支。 +// +// 职责边界: +// 1. 负责把本次请求接入 scheduleplan 执行器; +// 2. 负责注入排程依赖(SmartPlanning / BatchApplyPlans / GetTaskClassByID); +// 3. 负责对话历史获取,支持连续对话微调; +// 4. 不负责聊天持久化(由 AgentChat 主流程统一收口)。 +func (s *AgentService) runSchedulePlanFlow( + ctx context.Context, + selectedModel *ark.ChatModel, + userMessage string, + userID int, + chatID string, + traceID string, + extra map[string]any, + emitStage func(stage, detail string), + outChan chan<- string, + modelName string, +) (string, error) { + // 1. 依赖预检:排程依赖函数必须注入,否则无法完成排程链路。 + if s.SmartPlanningRawFunc == nil || s.BatchApplyPlansFunc == nil || s.GetTaskClassByIDFunc == nil { + return "", errors.New("schedule plan service dependencies are not ready") + } + if selectedModel == nil { + return "", errors.New("schedule plan model is nil") + } + + // 2. 获取对话历史,用于连续对话微调场景。 + // 优先从 Redis 读取,未命中时回源 DB。 + var chatHistory []*schema.Message + if s.agentCache != nil { + history, err := s.agentCache.GetHistory(ctx, chatID) + if err != nil { + log.Printf("获取排程对话历史失败 chat_id=%s: %v", chatID, err) + } else if history != nil { + chatHistory = history + } + } + + // 2.1 缓存未命中时回源 DB。 + if chatHistory == nil && s.repo != nil { + histories, hisErr := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), chatID) + if hisErr != nil { + log.Printf("回源 DB 获取排程对话历史失败 chat_id=%s: %v", chatID, hisErr) + } else { + chatHistory = conv.ToEinoMessages(histories) + } + } + + // 3. 初始化排程状态对象。 + state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID) + + // 4. 构建依赖注入并执行 graph。 + finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{ + Model: selectedModel, + State: state, + Deps: scheduleplan.SchedulePlanToolDeps{ + SmartPlanningRaw: s.SmartPlanningRawFunc, + BatchApplyPlans: s.BatchApplyPlansFunc, + GetTaskClassByID: s.GetTaskClassByIDFunc, + HybridScheduleWithPlan: s.HybridScheduleWithPlanFunc, + }, + UserMessage: userMessage, + Extra: extra, + ChatHistory: chatHistory, + EmitStage: emitStage, + OutChan: outChan, + ModelName: modelName, + }) + + if runErr != nil { + return "", runErr + } + + // 5. 提取最终回复。 + if finalState == nil { + return "排程流程异常,请稍后重试。", nil + } + + reply := strings.TrimSpace(finalState.FinalSummary) + if reply == "" { + reply = "排程流程已完成,但未生成结果摘要。" + } + + return reply, nil +} diff --git a/backend/service/schedule.go b/backend/service/schedule.go index c96ed91..1ea7e3e 100644 --- a/backend/service/schedule.go +++ b/backend/service/schedule.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log" + "strings" "time" "github.com/LoveLosita/smartflow/backend/conv" @@ -407,3 +408,162 @@ func (ss *ScheduleService) SmartPlanning(ctx context.Context, userID, taskClassI //5.将推荐的时间安排转换为前端需要的格式返回 return result, nil } + +// SmartPlanningRaw 执行粗排算法并同时返回展示结构和已分配的任务项。 +// +// 职责边界: +// 1) 与 SmartPlanning 共享完全相同的前置校验和粗排逻辑; +// 2) 额外返回 allocatedItems(每项的 EmbeddedTime 已由算法回填), +// 供 Agent 排程链路直接转换为 BatchApplyPlans 请求,无需再让模型"二次分配"。 +func (ss *ScheduleService) SmartPlanningRaw(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) { + // 1. 获取任务类详情。 + taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID) + if err != nil { + return nil, nil, err + } + if taskClass == nil { + return nil, nil, respond.WrongTaskClassID + } + if *taskClass.Mode != "auto" { + return nil, nil, respond.TaskClassModeNotAuto + } + + // 2. 获取时间范围内的全部日程。 + schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(ctx, userID, conv.CalculateFirstDayOfWeek(*taskClass.StartDate), conv.CalculateLastDayOfWeek(*taskClass.EndDate)) + if err != nil { + return nil, nil, err + } + + // 3. 执行粗排算法,拿到已分配的 items(EmbeddedTime 已回填)。 + allocatedItems, err := logic.SmartPlanningRawItems(schedules, taskClass) + if err != nil { + return nil, nil, err + } + + // 4. 同时生成展示结构,供 SSE 阶段推送给前端预览。 + displayResult := conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems) + return displayResult, allocatedItems, nil +} + +// HybridScheduleWithPlan 构建"混合日程":将既有日程与粗排建议合并为统一结构。 +// +// 职责边界: +// 1) 获取 TaskClass 时间范围内的既有日程(课程 + 已落库任务); +// 2) 调用粗排算法获取建议分配; +// 3) 将两者合并为 []HybridScheduleEntry,供 ReAct 精排引擎在内存中操作。 +// +// 返回值: +// - entries:混合日程条目(existing + suggested) +// - allocatedItems:粗排已分配的任务项(用于后续落库) +// - error +func (ss *ScheduleService) HybridScheduleWithPlan( + ctx context.Context, userID, taskClassID int, +) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) { + // 1. 获取任务类详情。 + taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID) + if err != nil { + return nil, nil, err + } + if taskClass == nil { + return nil, nil, respond.WrongTaskClassID + } + if *taskClass.Mode != "auto" { + return nil, nil, respond.TaskClassModeNotAuto + } + + // 2. 获取时间范围内的既有日程。 + schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange( + ctx, userID, + conv.CalculateFirstDayOfWeek(*taskClass.StartDate), + conv.CalculateLastDayOfWeek(*taskClass.EndDate), + ) + if err != nil { + return nil, nil, err + } + + // 3. 执行粗排算法。 + allocatedItems, err := logic.SmartPlanningRawItems(schedules, taskClass) + if err != nil { + return nil, nil, err + } + + // 4. 合并为 HybridScheduleEntry 切片。 + entries := make([]model.HybridScheduleEntry, 0, len(schedules)/2+len(allocatedItems)) + + // 4.1 既有日程:按 EventID+Week+DayOfWeek 分组,合并连续节次。 + type eventGroupKey struct { + EventID int + Week int + DayOfWeek int + } + type eventGroup struct { + Key eventGroupKey + Name string + Type string + Sections []int + } + groupMap := make(map[eventGroupKey]*eventGroup) + for _, s := range schedules { + key := eventGroupKey{EventID: s.EventID, Week: s.Week, DayOfWeek: s.DayOfWeek} + g, ok := groupMap[key] + if !ok { + name := "未知" + typ := "course" + if s.Event != nil { + name = s.Event.Name + typ = s.Event.Type + } + g = &eventGroup{Key: key, Name: name, Type: typ} + groupMap[key] = g + } + g.Sections = append(g.Sections, s.Section) + } + for _, g := range groupMap { + if len(g.Sections) == 0 { + continue + } + // 排序后取首尾作为 SectionFrom/SectionTo + minS, maxS := g.Sections[0], g.Sections[0] + for _, s := range g.Sections[1:] { + if s < minS { + minS = s + } + if s > maxS { + maxS = s + } + } + entries = append(entries, model.HybridScheduleEntry{ + Week: g.Key.Week, + DayOfWeek: g.Key.DayOfWeek, + SectionFrom: minS, + SectionTo: maxS, + Name: g.Name, + Type: g.Type, + Status: "existing", + EventID: g.Key.EventID, + }) + } + + // 4.2 粗排建议:每个已分配的 TaskClassItem 转为一条 suggested 条目。 + for _, item := range allocatedItems { + if item.EmbeddedTime == nil { + continue + } + name := "未命名任务" + if item.Content != nil && strings.TrimSpace(*item.Content) != "" { + name = strings.TrimSpace(*item.Content) + } + entries = append(entries, model.HybridScheduleEntry{ + Week: item.EmbeddedTime.Week, + DayOfWeek: item.EmbeddedTime.DayOfWeek, + SectionFrom: item.EmbeddedTime.SectionFrom, + SectionTo: item.EmbeddedTime.SectionTo, + Name: name, + Type: "task", + Status: "suggested", + TaskItemID: item.ID, + }) + } + + return entries, allocatedItems, nil +} diff --git a/backend/service/task-class.go b/backend/service/task-class.go index f3da581..7e89e46 100644 --- a/backend/service/task-class.go +++ b/backend/service/task-class.go @@ -315,6 +315,15 @@ func (sv *TaskClassService) DeleteTaskClass(ctx context.Context, userID int, tas return nil } +// GetCompleteTaskClassByID 获取任务类完整详情(含关联的 TaskClassItem 列表)。 +// +// 职责边界: +// 1) 直接委托 DAO 层查询,不做额外业务逻辑; +// 2) 主要供 Agent 排程链路使用,获取 Items 用于 materialize 节点映射。 +func (sv *TaskClassService) GetCompleteTaskClassByID(ctx context.Context, taskClassID, userID int) (*model.TaskClass, error) { + return sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID) +} + func (sv *TaskClassService) BatchApplyPlans(ctx context.Context, taskClassID int, userID int, plans *model.UserInsertTaskClassItemToScheduleRequestBatch) error { //1.通过任务类id获取任务类详情 taskClass, err := sv.taskClassRepo.GetCompleteTaskClassByID(ctx, taskClassID, userID) diff --git a/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md b/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md new file mode 100644 index 0000000..555cefa --- /dev/null +++ b/docs/功能决策记录/智能排程ReAct精排引擎_决策记录.md @@ -0,0 +1,150 @@ +# 智能排程 Agent — ReAct 精排引擎 决策记录 + +## 1. 基本信息 +- 记录编号:FDR-008 +- 功能名称:智能排程 ReAct 精排引擎(阶段 1.5:粗排 + AI 语义化微调) +- 记录日期:2026-03-19 +- 决策状态:已采纳,开发中 +- 负责人:SmartFlow 团队 +- 关联需求:FDR-007(智能排程 Agent 阶段 1) + +## 2. 背景与问题 +- 业务背景:阶段 1 已打通"意图识别 → 粗排 → 落库"全链路,但粗排算法是纯规则的线性分配(cursor-based),不考虑科目特性、学习效率曲线、上下文切换成本等语义因素。 +- 现状问题: + 1. 粗排结果机械化:高认知负荷科目可能被安排在低效时段(如晚间安排数学); + 2. 缺乏科目间协调:同类任务可能被分散到不连贯的时间段,增加上下文切换成本; + 3. 用户无法感知 AI 的"思考过程",排程结果缺乏可解释性。 +- 不做此决策的后果:排程质量停留在"能用但不好用"阶段,无法体现 AI 的语义理解能力。 + +## 3. 决策目标 +- 目标 1:在粗排之后引入 LLM 精排环节,通过 ReAct 范式对任务时间进行语义化优化。 +- 目标 2:精排过程中 LLM 的深度思考(reasoning)实时流式推送到前端,用户可见。 +- 目标 3:精排结果仅作为预览返回,不自动落库,用户确认后再持久化。 +- 目标 4:向后兼容——未注入精排依赖时自动走原有 materialize 路径。 +- 非目标: + - 本阶段不做用户确认后的落库链路(后续阶段); + - 本阶段不做 RAG 规则注入(阶段 3); + - 本阶段不做多方案对比(只输出一个优化后的方案)。 + +## 4. 备选方案 + +### 方案 A:后处理脚本(规则引擎) +- 描述:在粗排之后用硬编码规则(如"数学只排上午")做二次调整。 +- 优点:确定性强,无 LLM 调用开销。 +- 缺点:规则维护成本高,无法处理复杂的多科目协调;不可解释。 +- 复杂度 / 成本:低,但扩展性极差。 + +### 方案 B:ReAct 范式 + 手动 Tool 调用(采纳) +- 描述:LLM 开启深度思考,分析粗排结果后通过 Tool(Swap/Move/TimeAvailable/GetAvailableSlots)在内存中调整任务时间,多轮循环直到满意。 +- 优点: + 1. 充分利用 LLM 的语义理解能力,优化维度丰富; + 2. reasoning_content 实时推送,用户可见思考过程; + 3. Tool 操作内存数据,天然支持预览模式(不落库); + 4. 手动 ReAct 循环给予完全的流式控制权。 +- 缺点:依赖 LLM 输出质量;深度思考模式耗时较长。 +- 复杂度 / 成本:中高,约 1 周。 + +### 方案 C:Eino 内置 ToolsNode +- 描述:使用 Eino 框架的 ToolsNode + function_calling 原生能力。 +- 优点:框架原生支持,代码量少。 +- 缺点:无法在 Tool 执行过程中流式推送 reasoning_content;无法精细控制每轮 SSE 输出;项目中无现有 function_calling 基础设施。 +- 复杂度 / 成本:中,但灵活性不足。 + +## 5. 最终决策 +- 采纳方案:方案 B(ReAct 范式 + 手动 Tool 调用) +- 关键理由: + 1. 手动 ReAct 循环可以精确控制 SSE 流式输出(reasoning + stage + tool_call 穿插); + 2. Tool 操作纯内存数据,预览模式零风险; + 3. 与现有 graph 架构无缝集成(新增 3 个节点,不破坏原有链路)。 + +## 6. 技术方案 + +### 6.1 新流程(graph 结构) +``` +plan → preview(粗排) → hybridBuild(混合日程) → reactRefine(ReAct循环) → returnPreview → END + ↑ | + └────────────────────────┘ (tool失败重试,最多N轮) +``` +当 `HybridScheduleWithPlan` 依赖未注入时,preview 后自动走原有 materialize → apply 路径。 + +### 6.2 混合日程(HybridScheduleEntry) +将既有日程(existing)和粗排建议(suggested)统一到同一结构: +- `existing` + `course/task`:不可移动 +- `suggested` + `task`:LLM 可通过 Tool 调整 + +### 6.3 ReAct Tool 设计 +| Tool | 功能 | 操作对象 | +|------|------|---------| +| Swap | 交换两个 suggested 任务的时间 | 内存 HybridEntries | +| Move | 移动一个 suggested 任务到新时间 | 内存 HybridEntries | +| TimeAvailable | 检查目标时间是否可用 | 只读查询 | +| GetAvailableSlots | 返回可用时间段列表 | 只读查询 | + +### 6.4 SSE 推送设计 +- `schedule_plan.hybrid.building/done` — 混合日程构建阶段 +- `schedule_plan.react.round` — 第 N 轮优化开始 +- `reasoning_content` 流式 chunk — LLM 深度思考过程(实时推送) +- `schedule_plan.react.tool_call` — Tool 执行结果 +- `schedule_plan.react.done` — 优化完成 +- `schedule_plan.preview_return.done` — 预览生成完成 + +## 7. 影响范围 +- 新增文件: + - `backend/agent/scheduleplan/tools_react.go`:4 个 Tool + dispatcher + LLM 输出解析 + - `backend/agent/scheduleplan/react.go`:ReAct 循环核心 + 流式推送 +- 修改文件: + - `backend/model/schedule.go`:+HybridScheduleEntry + - `backend/agent/scheduleplan/state.go`:+ReAct 字段 + - `backend/agent/scheduleplan/prompt.go`:+ReAct system prompt + - `backend/agent/scheduleplan/nodes.go`:+hybridBuild/returnPreview 节点 + - `backend/agent/scheduleplan/runner.go`:+outChan/modelName/新节点适配 + - `backend/agent/scheduleplan/graph.go`:+3 节点/重新连线 + - `backend/agent/scheduleplan/tool.go`:+HybridScheduleWithPlan 依赖 + - `backend/service/schedule.go`:+HybridScheduleWithPlan 方法 + - `backend/service/agent_bridge.go`:+注入新依赖 + - `backend/service/agentsvc/agent.go`:+字段/传参 + - `backend/service/agentsvc/agent_schedule_plan.go`:+outChan/modelName/新依赖 +- 数据与存储影响:无。所有 Tool 操作纯内存,不涉及 DB。 +- 接口 / 协议影响:无新增接口。SSE 新增 react 相关阶段推送(向下兼容)。 + +## 8. 已知问题与后续优化 + +### 8.1 深度思考超时(当前) +- 现象:模型开启深度思考后 reasoning 阶段耗时较长,当前 5 分钟超时仍可能不够。 +- 影响:超时后使用粗排结果,精排未生效。 +- 后续方案: + - [ ] 调整超时策略(按模型实际耗时动态设置,或改为不设超时由父 context 控制) + - [ ] 优化 prompt,引导模型减少冗余推理 + - [ ] 评估是否关闭深度思考,改用普通模式 + 多轮调用换取稳定性 + +### 8.2 模型输出质量 +- 现象:模型思考过程较啰嗦,可能输出无效的 tool 调用。 +- 后续方案: + - [ ] 精炼 ReAct system prompt,加入 few-shot 示例 + - [ ] 对 tool_calls 做预校验,过滤明显无效的调用 + - [ ] 收集真实案例建立评测集 + +### 8.3 用户确认落库链路 +- 现象:当前精排结果仅预览,用户确认后的落库链路尚未实现。 +- 后续方案: + - [ ] 新增"确认落库"接口或对话指令 + - [ ] 复用现有 materialize → apply 路径,从 HybridEntries 转换 + +### 8.4 连续对话微调 +- 现象:精排后的连续对话微调(如"把数学挪到上午")尚未与 ReAct 引擎打通。 +- 后续方案: + - [ ] 将上一轮 HybridEntries 序列化到对话历史 + - [ ] 支持增量 ReAct(只调整用户指定的部分) + +## 9. 验证与回滚 +- 验证方式: + 1. `go build ./...` + `go vet ./...` 编译通过 + 2. 发送排程请求,验证 SSE 流中出现 react 阶段推送和 reasoning_content + 3. 验证不落库:数据库 schedules 表无新增记录 + 4. 向后兼容:不注入 HybridScheduleWithPlan 时走原有 materialize 路径 +- 回滚方案:在 `agent_bridge.go` 中注释掉 `HybridScheduleWithPlanFunc` 注入即可,preview 后自动回退到 materialize 路径。 + +## 10. 复盘结论(上线后补充) +- 实际效果:待补充 +- 与预期偏差:待补充 +- 后续是否需要二次决策:待补充 diff --git a/docs/智能排程四步走实施方案.md b/docs/智能排程四步走实施方案.md index 4fa36f7..9f6da46 100644 --- a/docs/智能排程四步走实施方案.md +++ b/docs/智能排程四步走实施方案.md @@ -63,6 +63,33 @@ - 应对:严格 JSON Schema 校验,失败直接走默认修补/人工规则。 - 回滚:关闭 `ENABLE_SCHEDULE_PLAN_AGENT`,回退到原接口链路。 +### 4.6 总流程规划 + +``` +任务目标:实现一个基于 ReAct 范式的智能排程微调引擎,将“粗排结果”与“既有日程”混合,并通过 AI 进行语义化优化。 +1.需要新建的前置函数: +(1)HybridScheduleWithPlan:从数据库中提取和排程时间范围相同的日程,放在sv/schedules.go里面,通过回调来作为一个节点然后调用(如果你有更好的结构建议,欢迎告诉我) +2.需要新建的tool(直接改State): +(1)Swap:LLM传入带交换两个任务的相对时间(从json中获取,第x周第x-x节),这个工具会自动寻找并交换时间(通过修改Schedule结构体内部数据实现的),找不到就报错。 +(2)Move:同上,传入一个任务的相对时间(第x周第x-x节),直接寻找并修改State中的Schedule中的时间。 +注意,上述(1)和(2)都必须带合法性检验。 +(3)timeAvailable:检测目标时间在当前日程中是否可用,用于服务(2)。 +(4)GetAvailableSlots:反馈给AI(json格式)可用时间的列表,用于让AI选择挪动过去的时间。 +3.基本流程如下: +(1)获取用户智能排程意图,提取task_class_id,调用SmartPlanning进行粗排,然后再通过上面的前置函数(1)将日程和已经安排好的任务混合,并传入State。 +(2)LLM启动深度思考(必须开深度思考),告诉它上述工具及其作用,让它自由选择调用。prompt你自己写,差不多就是: +考虑不同科目的"上下文切换成本",某科目更加适合学习的时间段以及人一天的学习效率曲线等因素,修改上述json中status为suggested且type为task的任务,最终形成无论从复习效果,还是学习体感上来看都十分科学合理的学习方案。 +(3)此时,模型开启深度思考,推送reasoning stream到前端,和既定的状态chunk穿插。 +(4)在思考中,模型一次看好改动逻辑(这就是为啥要开深度思考的原因,逻辑有点绕),然后思考结束,出结果后一次性调用这些tool。 +注意,这里有备选方案,如果模型逻辑不够,那就一次只调一次tool,多调用几次llm,这样用时间换正确率。 +(4.1)若调用成功,则直接返回排程结果到前端(禁止落库,用户得看效果再决定是否正式落库,而正式落库用不着agent) +(4.2)若失败,则把失败原因返回LLM,LLM再看情况自己思考并重试。 + + +``` + + + --- ## 阶段 2:从“我想复习概率论”自动生成任务类,并接入阶段 1