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