diff --git a/AGENTS.md b/AGENTS.md
index bb402cb..b784d36 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,8 +13,8 @@
9. 对于明显过大的文件(尤其是同时承载编排、业务、模型交互、工具分发的文件),后续重构时必须拆分职责,禁止继续向单文件堆砌新逻辑。
10. Prompt、State、模型交互、Graph 连线应尽量分目录/分文件管理,禁止把大段 prompt、节点逻辑、模型 helper 长期混写在同一文件中。
11. 若本轮任务包含“结构迁移”,最终答复中必须明确说明:本轮迁了什么、哪些旧实现仍保留、当前切流点在哪里、下一轮建议迁什么。
-
12. 若后续在 `backend/agent2` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent2/通用能力接入文档.md`,否则视为重构信息不完整。
+13. 跑完单元测试后,必须删除单元测试的test.go文件,禁止把测试文件长期留在项目中。
## 注释规范(强制)
diff --git a/backend/agent2/graph/schedule.go b/backend/agent2/graph/schedule.go
index bd5a78c..d34e997 100644
--- a/backend/agent2/graph/schedule.go
+++ b/backend/agent2/graph/schedule.go
@@ -1,30 +1,164 @@
package agentgraph
-import agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
+import (
+ "context"
+ "errors"
-const (
- SchedulePlanGraphName = "schedule_plan"
- ScheduleRefineGraphName = "schedule_refine"
-
- ScheduleNodeIntentRoute = "schedule.intent.route"
- ScheduleNodePlan = "schedule.plan"
- ScheduleNodeRoughBuild = "schedule.rough_build"
- ScheduleNodeReact = "schedule.react"
- ScheduleNodeHardCheck = "schedule.hard_check"
- ScheduleNodeReply = "schedule.reply"
+ agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
+ agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
+ "github.com/cloudwego/eino/compose"
)
-// SchedulePlanGraph 是“首次排程”图编排骨架。
-type SchedulePlanGraph struct {
- Nodes *agentnode.SchedulePlanNodes
+const (
+ // SchedulePlanGraphName 是首次排程 graph 的稳定标识。
+ SchedulePlanGraphName = "schedule_plan"
+ // ScheduleRefineGraphName 先保留给 refine 链路使用。
+ ScheduleRefineGraphName = "schedule_refine"
+)
+
+// RunSchedulePlanGraph 执行“智能排程”图编排。
+//
+// 当前链路:
+// START
+// -> plan
+// -> roughBuild
+// -> (len(task_class_ids)>=2 ? dailySplit -> dailyRefine -> merge : weeklyRefine)
+// -> finalCheck
+// -> returnPreview
+// -> END
+//
+// 说明:
+// 1. exit 分支可从 plan/roughBuild 直接提前终止;
+// 2. 本文件只负责“连线与分支”,节点内业务都在 node 层实现;
+// 3. 这轮已经去掉旧 runner 适配层,graph 直接挂 node 方法,减少一跳阅读成本。
+func RunSchedulePlanGraph(ctx context.Context, input agentnode.SchedulePlanGraphRunInput) (*agentmodel.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. 注入运行时配置(可选覆盖)。
+ if input.DailyRefineConcurrency > 0 {
+ input.State.DailyRefineConcurrency = input.DailyRefineConcurrency
+ }
+ if input.WeeklyAdjustBudget > 0 {
+ input.State.WeeklyAdjustBudget = input.WeeklyAdjustBudget
+ }
+
+ nodes, err := agentnode.NewSchedulePlanNodes(input)
+ if err != nil {
+ return nil, err
+ }
+
+ graph := compose.NewGraph[*agentmodel.SchedulePlanState, *agentmodel.SchedulePlanState]()
+
+ // 3. 注册节点。
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodePlan, compose.InvokableLambda(nodes.Plan)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeRoughBuild, compose.InvokableLambda(nodes.RoughBuild)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeExit, compose.InvokableLambda(nodes.Exit)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailySplit, compose.InvokableLambda(nodes.DailySplit)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeQuickRefine, compose.InvokableLambda(nodes.QuickRefine)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeDailyRefine, compose.InvokableLambda(nodes.DailyRefine)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeMerge, compose.InvokableLambda(nodes.Merge)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeWeeklyRefine, compose.InvokableLambda(nodes.WeeklyRefine)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeFinalCheck, compose.InvokableLambda(nodes.FinalCheck)); err != nil {
+ return nil, err
+ }
+ if err = graph.AddLambdaNode(agentnode.SchedulePlanGraphNodeReturnPreview, compose.InvokableLambda(nodes.ReturnPreview)); err != nil {
+ return nil, err
+ }
+
+ // 4. 固定入口:START -> plan。
+ if err = graph.AddEdge(compose.START, agentnode.SchedulePlanGraphNodePlan); err != nil {
+ return nil, err
+ }
+
+ // 5. plan 分支:roughBuild | exit。
+ if err = graph.AddBranch(agentnode.SchedulePlanGraphNodePlan, compose.NewGraphBranch(
+ nodes.NextAfterPlan,
+ map[string]bool{
+ agentnode.SchedulePlanGraphNodeRoughBuild: true,
+ agentnode.SchedulePlanGraphNodeExit: true,
+ },
+ )); err != nil {
+ return nil, err
+ }
+
+ // 6. roughBuild 分支:dailySplit | quickRefine | weeklyRefine | exit。
+ if err = graph.AddBranch(agentnode.SchedulePlanGraphNodeRoughBuild, compose.NewGraphBranch(
+ nodes.NextAfterRoughBuild,
+ map[string]bool{
+ agentnode.SchedulePlanGraphNodeDailySplit: true,
+ agentnode.SchedulePlanGraphNodeQuickRefine: true,
+ agentnode.SchedulePlanGraphNodeWeeklyRefine: true,
+ agentnode.SchedulePlanGraphNodeExit: true,
+ },
+ )); err != nil {
+ return nil, err
+ }
+
+ // 7. 固定边:quickRefine -> weeklyRefine;dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END。
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeQuickRefine, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil {
+ return nil, err
+ }
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailySplit, agentnode.SchedulePlanGraphNodeDailyRefine); err != nil {
+ return nil, err
+ }
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeDailyRefine, agentnode.SchedulePlanGraphNodeMerge); err != nil {
+ return nil, err
+ }
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeMerge, agentnode.SchedulePlanGraphNodeWeeklyRefine); err != nil {
+ return nil, err
+ }
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeWeeklyRefine, agentnode.SchedulePlanGraphNodeFinalCheck); err != nil {
+ return nil, err
+ }
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeFinalCheck, agentnode.SchedulePlanGraphNodeReturnPreview); err != nil {
+ return nil, err
+ }
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeReturnPreview, compose.END); err != nil {
+ return nil, err
+ }
+ if err = graph.AddEdge(agentnode.SchedulePlanGraphNodeExit, compose.END); err != nil {
+ return nil, err
+ }
+
+ // 8. 编译并执行。
+ // 路径最多约 8~9 个节点,保守预留 20 步避免误判。
+ runnable, err := graph.Compile(ctx,
+ compose.WithGraphName(SchedulePlanGraphName),
+ compose.WithMaxRunSteps(20),
+ compose.WithNodeTriggerMode(compose.AnyPredecessor),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return runnable.Invoke(ctx, input.State)
}
-// NewSchedulePlanGraph 创建首次排程图骨架。
-func NewSchedulePlanGraph(nodes *agentnode.SchedulePlanNodes) *SchedulePlanGraph {
- return &SchedulePlanGraph{Nodes: nodes}
-}
-
-// ScheduleRefineGraph 是“连续微调排程”图编排骨架。
+// ScheduleRefineGraph 先保留骨架,避免本轮“只迁 schedule_plan”时误动 refine 主链路。
type ScheduleRefineGraph struct {
Nodes *agentnode.ScheduleRefineNodes
}
diff --git a/backend/agent2/llm/schedule.go b/backend/agent2/llm/schedule.go
index 03ae545..6b2bd69 100644
--- a/backend/agent2/llm/schedule.go
+++ b/backend/agent2/llm/schedule.go
@@ -1,22 +1,175 @@
package agentllm
-// ScheduleIntentOutput 是智能排程一级意图识别的模型契约草案。
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
+ "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"
+)
+
+// ScheduleIntentOutput 是 plan 节点要求模型返回的结构化结果。
+//
+// 兼容说明:
+// 1. 新主语义是 task_class_ids(数组);
+// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析;
+// 3. TaskTags 的 key 兼容两种写法:
+// 3.1 推荐:task_item_id(例如 "12");
+// 3.2 兼容:任务名称(例如 "高数复习")。
type ScheduleIntentOutput struct {
- Intent string `json:"intent"`
- NeedRefine bool `json:"need_refine"`
- AdjustmentScope string `json:"adjustment_scope"`
+ Intent string `json:"intent"`
+ Constraints []string `json:"constraints"`
+ TaskClassIDs []int `json:"task_class_ids"`
+ TaskClassID int `json:"task_class_id"`
+ Strategy string `json:"strategy"`
+ TaskTags map[string]string `json:"task_tags"`
+ Restart bool `json:"restart"`
+ AdjustmentScope string `json:"adjustment_scope"`
+ Reason string `json:"reason"`
+ Confidence float64 `json:"confidence"`
}
-// SchedulePlanOutput 是首次排程规划节点的模型契约草案。
-type SchedulePlanOutput struct {
- Goal string `json:"goal"`
- Constraints []string `json:"constraints"`
- Strategy string `json:"strategy"`
+// ReactToolCall 是 LLM 输出的单个工具调用。
+type ReactToolCall struct {
+ Tool string `json:"tool"`
+ Params map[string]any `json:"params"`
}
-// ScheduleRefineOutput 是连续微调阶段的模型契约草案。
-type ScheduleRefineOutput struct {
- Decision string `json:"decision"`
- HardAssertions []string `json:"hard_assertions"`
- NextAction string `json:"next_action"`
+// ReactLLMOutput 是 ReAct 节点要求模型返回的统一 JSON。
+type ReactLLMOutput struct {
+ Done bool `json:"done"`
+ Summary string `json:"summary"`
+ ToolCalls []ReactToolCall `json:"tool_calls"`
+}
+
+// IdentifySchedulePlanIntent 调用模型识别“排程意图 + 约束 + 任务类集合”。
+func IdentifySchedulePlanIntent(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ nowText string,
+ userMessage string,
+ adjustmentHint string,
+) (*ScheduleIntentOutput, error) {
+ prompt := fmt.Sprintf(
+ "当前时间(北京时间):%s\n用户输入:%s%s\n\n请提取排程意图与约束。",
+ strings.TrimSpace(nowText),
+ strings.TrimSpace(userMessage),
+ strings.TrimSpace(adjustmentHint),
+ )
+
+ parsed, _, err := CallArkJSON[ScheduleIntentOutput](ctx, chatModel, agentprompt.SchedulePlanIntentPrompt, prompt, ArkCallOptions{
+ Temperature: 0,
+ MaxTokens: 256,
+ Thinking: ThinkingModeDisabled,
+ })
+ return parsed, err
+}
+
+// ParseScheduleReactOutput 解析 ReAct 节点的 JSON 输出。
+func ParseScheduleReactOutput(raw string) (*ReactLLMOutput, error) {
+ return ParseJSONObject[ReactLLMOutput](raw)
+}
+
+// GenerateScheduleDailyReactRound 调用模型生成“单天日内优化”的一轮决策。
+//
+// 职责边界:
+// 1. 只负责统一关闭 thinking、设置温度,并返回纯文本;
+// 2. 不负责工具执行,不负责结果回灌。
+func GenerateScheduleDailyReactRound(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ messages []*schema.Message,
+) (string, error) {
+ resp, err := chatModel.Generate(
+ ctx,
+ messages,
+ ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
+ einoModel.WithTemperature(0),
+ )
+ if err != nil {
+ return "", err
+ }
+ if resp == nil {
+ return "", fmt.Errorf("日内优化调用返回为空")
+ }
+ content := strings.TrimSpace(resp.Content)
+ if content == "" {
+ return "", fmt.Errorf("日内优化调用返回内容为空")
+ }
+ return content, nil
+}
+
+// GenerateScheduleWeeklyReactRound 调用模型生成“单周单步优化”的一轮决策。
+//
+// 职责边界:
+// 1. 周级仍保留 thinking,提高复杂排程准确率;
+// 2. 仅返回最终 content,是否透出思考流由上层决定。
+func GenerateScheduleWeeklyReactRound(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ messages []*schema.Message,
+) (string, error) {
+ resp, err := chatModel.Generate(
+ ctx,
+ messages,
+ ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeEnabled}),
+ einoModel.WithTemperature(0.2),
+ )
+ if err != nil {
+ return "", err
+ }
+ if resp == nil {
+ return "", fmt.Errorf("周级单步调用返回为空")
+ }
+ content := strings.TrimSpace(resp.Content)
+ if content == "" {
+ return "", fmt.Errorf("周级单步调用返回内容为空")
+ }
+ return content, nil
+}
+
+// GenerateScheduleHumanSummary 调用模型生成“用户可读”的最终总结。
+func GenerateScheduleHumanSummary(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ entries []model.HybridScheduleEntry,
+ constraints []string,
+ actionLogs []string,
+) (string, error) {
+ if chatModel == nil {
+ return "", fmt.Errorf("final summary model is nil")
+ }
+
+ entriesJSON, _ := json.Marshal(entries)
+ constraintText := "无"
+ if len(constraints) > 0 {
+ constraintText = strings.Join(constraints, "、")
+ }
+ actionLogText := "无"
+ if len(actionLogs) > 0 {
+ start := 0
+ if len(actionLogs) > 30 {
+ start = len(actionLogs) - 30
+ }
+ actionLogText = strings.Join(actionLogs[start:], "\n")
+ }
+
+ userPrompt := fmt.Sprintf(
+ "以下是最终排程方案(JSON):\n%s\n\n用户约束:%s\n\n以下是本次周级优化动作日志(按时间顺序):\n%s\n\n请基于“结果+过程”输出2-3句自然中文总结,重点说明本方案的优点和改进点。",
+ string(entriesJSON),
+ constraintText,
+ actionLogText,
+ )
+
+ return CallArkText(ctx, chatModel, agentprompt.SchedulePlanFinalCheckPrompt, userPrompt, ArkCallOptions{
+ Temperature: 0.4,
+ MaxTokens: 256,
+ Thinking: ThinkingModeDisabled,
+ })
}
diff --git a/backend/agent2/model/schedule.go b/backend/agent2/model/schedule.go
index 6b305f8..8bce757 100644
--- a/backend/agent2/model/schedule.go
+++ b/backend/agent2/model/schedule.go
@@ -1,18 +1,205 @@
package agentmodel
-// SchedulePlanState 是“首次排程”skill 的运行时状态骨架。
-type SchedulePlanState struct {
- TraceID string
- UserID int
- ConversationID string
- UserInput string
- TaskClassIDs []int
- UseQuickRefineOnly bool
- Completed bool
- FinalSummary string
+import (
+ "strings"
+ "time"
+
+ "github.com/LoveLosita/smartflow/backend/model"
+)
+
+const (
+ // SchedulePlanTimezoneName 是排程链路默认业务时区。
+ // 与随口记保持一致,固定东八区,避免容器运行在 UTC 导致“明天/今晚”偏移。
+ SchedulePlanTimezoneName = "Asia/Shanghai"
+
+ // SchedulePlanDatetimeLayout 是排程链路内部统一的分钟级时间格式。
+ SchedulePlanDatetimeLayout = "2006-01-02 15:04"
+
+ // SchedulePlanDefaultDailyRefineConcurrency 是日内并发优化默认并发度。
+ // 这里给一个保守默认值,避免未配置时直接把模型并发打满导致限流。
+ SchedulePlanDefaultDailyRefineConcurrency = 3
+
+ // SchedulePlanDefaultWeeklyAdjustBudget 是周级配平默认调整额度。
+ // 额度存在的目的:
+ // 1. 防止周级 ReAct 过度调整导致震荡;
+ // 2. 控制 token 与时延成本;
+ // 3. 让方案改动更可解释。
+ SchedulePlanDefaultWeeklyAdjustBudget = 5
+
+ // SchedulePlanDefaultWeeklyTotalBudget 是周级“总尝试次数”默认预算。
+ //
+ // 设计意图:
+ // 1. 总预算统计“动作尝试次数”(成功/失败都记一次);
+ // 2. 有效预算统计“成功动作次数”(仅成功时记一次);
+ // 3. 通过双预算把“探索次数”和“有效改动次数”分离,降低模型无效空转成本。
+ SchedulePlanDefaultWeeklyTotalBudget = 8
+
+ // SchedulePlanDefaultWeeklyRefineConcurrency 是周级“按周并发”默认并发度。
+ // 说明:
+ // 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流;
+ // 2. 可在运行时按请求状态覆盖。
+ SchedulePlanDefaultWeeklyRefineConcurrency = 2
+
+ // SchedulePlanAdjustmentScopeSmall 表示“小改动微调”。
+ // 语义:优先走快速路径,只做轻量周级调整。
+ SchedulePlanAdjustmentScopeSmall = "small"
+ // SchedulePlanAdjustmentScopeMedium 表示“中等改动微调”。
+ // 语义:跳过日内拆分,直接进入周级配平。
+ SchedulePlanAdjustmentScopeMedium = "medium"
+ // SchedulePlanAdjustmentScopeLarge 表示“大改动重排”。
+ // 语义:必要时重新走全量路径(日内并发 + 周级配平)。
+ SchedulePlanAdjustmentScopeLarge = "large"
+)
+
+const (
+ schedulePlanTimezoneName = SchedulePlanTimezoneName
+ schedulePlanDatetimeLayout = SchedulePlanDatetimeLayout
+ schedulePlanDefaultDailyRefineConcurrency = SchedulePlanDefaultDailyRefineConcurrency
+ schedulePlanDefaultWeeklyAdjustBudget = SchedulePlanDefaultWeeklyAdjustBudget
+ schedulePlanDefaultWeeklyTotalBudget = SchedulePlanDefaultWeeklyTotalBudget
+ schedulePlanDefaultWeeklyRefineConcurrency = SchedulePlanDefaultWeeklyRefineConcurrency
+ schedulePlanAdjustmentScopeSmall = SchedulePlanAdjustmentScopeSmall
+ schedulePlanAdjustmentScopeMedium = SchedulePlanAdjustmentScopeMedium
+ schedulePlanAdjustmentScopeLarge = SchedulePlanAdjustmentScopeLarge
+)
+
+// DayGroup 是“按天拆分后”的最小优化单元。
+//
+// 设计目的:
+// 1. 把全量周视角数据拆成“单天小包”,降低日内 ReAct 输入规模;
+// 2. 支持并发优化不同天的数据,缩短整体等待;
+// 3. 通过 SkipRefine 让低收益天数直接跳过,节省模型调用成本。
+type DayGroup struct {
+ Week int
+ DayOfWeek int
+ Entries []model.HybridScheduleEntry
+ SkipRefine bool
}
-// ScheduleRefineState 是“连续微调排程”skill 的运行时状态骨架。
+// SchedulePlanState 是“智能排程”链路在 graph 节点间传递的统一状态容器。
+//
+// 设计目标:
+// 1) 收拢排程请求全生命周期的上下文,降低节点间参数散落;
+// 2) 支持“粗排 -> 日内并发优化 -> 周级配平 -> 终审校验”的完整链路追踪;
+// 3) 支持连续对话微调:保留上版方案 + 本次约束变更,便于增量重排。
+type SchedulePlanState struct {
+ // ── 基础上下文 ──
+ TraceID string
+ UserID int
+ ConversationID string
+ RequestNow time.Time
+ RequestNowText string
+
+ // ── plan 节点输出 ──
+ UserIntent string
+ Constraints []string
+ TaskClassIDs []int
+ Strategy string
+ TaskTags map[int]string
+ TaskTagHintsByName map[string]string
+
+ // ── preview 节点输出 ──
+ CandidatePlans []model.UserWeekSchedule
+ AllocatedItems []model.TaskClassItem
+ HasPlanningWindow bool
+ PlanStartWeek int
+ PlanStartDay int
+ PlanEndWeek int
+ PlanEndDay int
+
+ // ── 日内并发优化阶段 ──
+ DailyGroups map[int]map[int]*DayGroup
+ DailyResults map[int]map[int][]model.HybridScheduleEntry
+ DailyRefineConcurrency int
+
+ // ── 周级 ReAct 精排阶段 ──
+ HybridEntries []model.HybridScheduleEntry
+ MergeSnapshot []model.HybridScheduleEntry
+ ReactRound int
+ ReactMaxRound int
+ ReactSummary string
+ ReactDone bool
+ WeeklyAdjustBudget int
+ WeeklyAdjustUsed int
+ WeeklyTotalBudget int
+ WeeklyTotalUsed int
+ WeeklyRefineConcurrency int
+ WeeklyActionLogs []string
+
+ // ── 连续对话微调 ──
+ PreviousPlanJSON string
+ IsAdjustment bool
+ RestartRequested bool
+ AdjustmentScope string
+ AdjustmentReason string
+ AdjustmentConfidence float64
+ HasPreviousPreview bool
+ PreviousTaskClassIDs []int
+ PreviousHybridEntries []model.HybridScheduleEntry
+ PreviousAllocatedItems []model.TaskClassItem
+ PreviousCandidatePlans []model.UserWeekSchedule
+
+ // ── 最终输出 ──
+ FinalSummary string
+ 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),
+ Strategy: "steady",
+ TaskTags: make(map[int]string),
+ TaskTagHintsByName: make(map[string]string),
+ DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency,
+ WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency,
+ AdjustmentScope: schedulePlanAdjustmentScopeLarge,
+ ReactMaxRound: 2,
+ WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget,
+ WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget,
+ }
+}
+
+// NormalizeSchedulePlanAdjustmentScope 归一化排程微调力度字段。
+//
+// 兜底策略:
+// 1. 只接受 small/medium/large;
+// 2. 任何未知值都回退为 large,保证不会误走“过轻”路径。
+func NormalizeSchedulePlanAdjustmentScope(raw string) string {
+ switch strings.ToLower(strings.TrimSpace(raw)) {
+ case schedulePlanAdjustmentScopeSmall:
+ return schedulePlanAdjustmentScopeSmall
+ case schedulePlanAdjustmentScopeMedium:
+ return schedulePlanAdjustmentScopeMedium
+ default:
+ return schedulePlanAdjustmentScopeLarge
+ }
+}
+
+// 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)
+}
+
+func normalizeAdjustmentScope(raw string) string {
+ return NormalizeSchedulePlanAdjustmentScope(raw)
+}
+
+// ScheduleRefineState 先保留现有骨架,避免本轮“只迁 schedule_plan”时误动 refine。
type ScheduleRefineState struct {
TraceID string
UserID int
diff --git a/backend/agent2/node/schedule_plan.go b/backend/agent2/node/schedule_plan.go
index 53e1195..b6106e6 100644
--- a/backend/agent2/node/schedule_plan.go
+++ b/backend/agent2/node/schedule_plan.go
@@ -1,25 +1,2336 @@
package agentnode
import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
- agentstream "github.com/LoveLosita/smartflow/backend/agent2/stream"
+ agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
+ agentprompt "github.com/LoveLosita/smartflow/backend/agent2/prompt"
+ agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
+ "github.com/LoveLosita/smartflow/backend/model"
+ "github.com/cloudwego/eino-ext/components/model/ark"
+ "github.com/cloudwego/eino/schema"
)
-// SchedulePlanNodeDeps 描述“首次排程”节点层公共依赖。
-type SchedulePlanNodeDeps struct {
- LLM *agentllm.Client
- StageEmitter agentstream.StageEmitter
+const (
+ // SchedulePlanGraphNodePlan 是“识别排程意图与约束”的节点名。
+ SchedulePlanGraphNodePlan = "schedule_plan_plan"
+ // SchedulePlanGraphNodeRoughBuild 是“粗排构建”的节点名。
+ SchedulePlanGraphNodeRoughBuild = "schedule_plan_rough_build"
+ // SchedulePlanGraphNodeExit 是“提前退出”的节点名。
+ SchedulePlanGraphNodeExit = "schedule_plan_exit"
+ // SchedulePlanGraphNodeDailySplit 是“按天拆分”的节点名。
+ SchedulePlanGraphNodeDailySplit = "schedule_plan_daily_split"
+ // SchedulePlanGraphNodeQuickRefine 是“小改动快速微调”的节点名。
+ SchedulePlanGraphNodeQuickRefine = "schedule_plan_quick_refine"
+ // SchedulePlanGraphNodeDailyRefine 是“并发日内优化”的节点名。
+ SchedulePlanGraphNodeDailyRefine = "schedule_plan_daily_refine"
+ // SchedulePlanGraphNodeMerge 是“合并日内优化结果”的节点名。
+ SchedulePlanGraphNodeMerge = "schedule_plan_merge"
+ // SchedulePlanGraphNodeWeeklyRefine 是“周级配平优化”的节点名。
+ SchedulePlanGraphNodeWeeklyRefine = "schedule_plan_weekly_refine"
+ // SchedulePlanGraphNodeFinalCheck 是“终审校验”的节点名。
+ SchedulePlanGraphNodeFinalCheck = "schedule_plan_final_check"
+ // SchedulePlanGraphNodeReturnPreview 是“返回预览结果”的节点名。
+ SchedulePlanGraphNodeReturnPreview = "schedule_plan_return_preview"
+)
+
+const (
+ schedulePlanGraphNodePlan = SchedulePlanGraphNodePlan
+ schedulePlanGraphNodeRoughBuild = SchedulePlanGraphNodeRoughBuild
+ schedulePlanGraphNodeExit = SchedulePlanGraphNodeExit
+ schedulePlanGraphNodeDailySplit = SchedulePlanGraphNodeDailySplit
+ schedulePlanGraphNodeQuickRefine = SchedulePlanGraphNodeQuickRefine
+ schedulePlanGraphNodeDailyRefine = SchedulePlanGraphNodeDailyRefine
+ schedulePlanGraphNodeMerge = SchedulePlanGraphNodeMerge
+ schedulePlanGraphNodeWeeklyRefine = SchedulePlanGraphNodeWeeklyRefine
+ schedulePlanGraphNodeFinalCheck = SchedulePlanGraphNodeFinalCheck
+ schedulePlanGraphNodeReturnPreview = SchedulePlanGraphNodeReturnPreview
+)
+
+const (
+ schedulePlanDefaultDailyRefineConcurrency = agentmodel.SchedulePlanDefaultDailyRefineConcurrency
+ schedulePlanDefaultWeeklyAdjustBudget = agentmodel.SchedulePlanDefaultWeeklyAdjustBudget
+ schedulePlanDefaultWeeklyTotalBudget = agentmodel.SchedulePlanDefaultWeeklyTotalBudget
+ schedulePlanDefaultWeeklyRefineConcurrency = agentmodel.SchedulePlanDefaultWeeklyRefineConcurrency
+ schedulePlanAdjustmentScopeSmall = agentmodel.SchedulePlanAdjustmentScopeSmall
+ schedulePlanAdjustmentScopeMedium = agentmodel.SchedulePlanAdjustmentScopeMedium
+ schedulePlanAdjustmentScopeLarge = agentmodel.SchedulePlanAdjustmentScopeLarge
+)
+
+type (
+ // SchedulePlanState 是 node 层对排程状态的本地别名。
+ // 这样做的目的,是让节点文件在迁移期保持旧逻辑可读,不需要把每个类型都写成长前缀。
+ SchedulePlanState = agentmodel.SchedulePlanState
+ // DayGroup 是按天拆分后的最小优化单元别名。
+ DayGroup = agentmodel.DayGroup
+)
+
+// SchedulePlanGraphRunInput 是执行“智能排程 graph”所需输入。
+//
+// 字段说明:
+// 1. Extra:前端附加参数(重点是 task_class_ids);
+// 2. ChatHistory:支持连续对话微调;
+// 3. OutChan/ModelName:保留兼容字段(当前 weekly refine 主要输出阶段状态);
+// 4. DailyRefineConcurrency/WeeklyAdjustBudget:可选运行参数覆盖。
+type SchedulePlanGraphRunInput struct {
+ Model *ark.ChatModel
+ State *agentmodel.SchedulePlanState
+ Deps SchedulePlanToolDeps
+ UserMessage string
+ Extra map[string]any
+ ChatHistory []*schema.Message
+ EmitStage func(stage, detail string)
+
+ OutChan chan<- string
+ ModelName string
+
+ DailyRefineConcurrency int
+ WeeklyAdjustBudget int
}
-// SchedulePlanNodes 是“首次排程”节点逻辑容器。
+// SchedulePlanNodes 是“首次排程”图的节点容器。
+//
+// 职责边界:
+// 1. 负责收口请求级依赖(model / extra / history / stage emitter);
+// 2. 负责向 graph 层暴露可直接挂载的方法;
+// 3. 不负责 graph 编译,也不负责 service 层接线。
type SchedulePlanNodes struct {
- deps SchedulePlanNodeDeps
+ input SchedulePlanGraphRunInput
+ emitStage func(stage, detail string)
}
-// NewSchedulePlanNodes 创建首次排程节点容器。
-func NewSchedulePlanNodes(deps SchedulePlanNodeDeps) *SchedulePlanNodes {
- if deps.StageEmitter == nil {
- deps.StageEmitter = agentstream.NoopStageEmitter()
+// NewSchedulePlanNodes 创建排程节点容器。
+//
+// 职责边界:
+// 1. 负责校验“图运行的最小依赖”是否齐全;
+// 2. 负责把空的阶段回调收敛成 no-op,避免节点内部到处判空;
+// 3. 不负责调整 state 业务字段,state 预处理由 graph 层完成。
+func NewSchedulePlanNodes(input SchedulePlanGraphRunInput) (*SchedulePlanNodes, error) {
+ if input.Model == nil {
+ return nil, errors.New("schedule plan nodes: model is nil")
}
- return &SchedulePlanNodes{deps: deps}
+ if input.State == nil {
+ return nil, errors.New("schedule plan nodes: state is nil")
+ }
+ if err := input.Deps.Validate(); err != nil {
+ return nil, err
+ }
+
+ emitStage := input.EmitStage
+ if emitStage == nil {
+ emitStage = func(stage, detail string) {}
+ }
+ return &SchedulePlanNodes{
+ input: input,
+ emitStage: emitStage,
+ }, nil
+}
+
+// Plan 负责承接“排程意图分析”节点。
+func (n *SchedulePlanNodes) Plan(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runPlanNode(ctx, st, n.input.Model, n.input.UserMessage, n.input.Extra, n.input.ChatHistory, n.emitStage)
+}
+
+// RoughBuild 负责承接“粗排构建”节点。
+func (n *SchedulePlanNodes) RoughBuild(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runRoughBuildNode(ctx, st, n.input.Deps, n.emitStage)
+}
+
+// DailySplit 负责承接“按天拆分”节点。
+func (n *SchedulePlanNodes) DailySplit(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runDailySplitNode(ctx, st, n.emitStage)
+}
+
+// QuickRefine 负责承接“小改动快速微调”节点。
+func (n *SchedulePlanNodes) QuickRefine(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runQuickRefineNode(ctx, st, n.emitStage)
+}
+
+// DailyRefine 负责承接“并发日内优化”节点。
+func (n *SchedulePlanNodes) DailyRefine(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runDailyRefineNode(ctx, st, n.input.Model, n.input.DailyRefineConcurrency, n.emitStage)
+}
+
+// Merge 负责承接“合并日内优化结果”节点。
+func (n *SchedulePlanNodes) Merge(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runMergeNode(ctx, st, n.emitStage)
+}
+
+// WeeklyRefine 负责承接“周级配平优化”节点。
+func (n *SchedulePlanNodes) WeeklyRefine(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runWeeklyRefineNode(ctx, st, n.input.Model, n.input.OutChan, n.input.ModelName, n.emitStage)
+}
+
+// FinalCheck 负责承接“终审校验”节点。
+func (n *SchedulePlanNodes) FinalCheck(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runFinalCheckNode(ctx, st, n.input.Model, n.emitStage)
+}
+
+// ReturnPreview 负责承接“生成结构化预览输出”节点。
+func (n *SchedulePlanNodes) ReturnPreview(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ return runReturnPreviewNode(ctx, st, n.emitStage)
+}
+
+// Exit 是图中的显式退出节点。
+//
+// 职责边界:
+// 1. 只作为图收口占位,保持状态原样透传;
+// 2. 不做额外副作用,避免“退出节点偷偷改状态”。
+func (n *SchedulePlanNodes) Exit(ctx context.Context, st *agentmodel.SchedulePlanState) (*agentmodel.SchedulePlanState, error) {
+ _ = ctx
+ return st, nil
+}
+
+// NextAfterPlan 根据 plan 节点结果决定下一步。
+func (n *SchedulePlanNodes) NextAfterPlan(ctx context.Context, st *agentmodel.SchedulePlanState) (string, error) {
+ _ = ctx
+ return selectNextAfterPlan(st), nil
+}
+
+// NextAfterRoughBuild 根据粗排构建结果决定后续路径。
+//
+// 规则:
+// 1. 没有可优化条目 -> exit;
+// 2. 连续微调且判定为 small -> quickRefine;
+// 3. 连续微调且判定为 medium -> weeklyRefine;
+// 4. large 或非微调:多任务类走 dailySplit,单任务类直达 weeklyRefine。
+func (n *SchedulePlanNodes) NextAfterRoughBuild(ctx context.Context, st *agentmodel.SchedulePlanState) (string, error) {
+ _ = ctx
+ if st == nil || len(st.HybridEntries) == 0 {
+ return SchedulePlanGraphNodeExit, nil
+ }
+ if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeSmall {
+ return SchedulePlanGraphNodeQuickRefine, nil
+ }
+ if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeMedium {
+ return SchedulePlanGraphNodeWeeklyRefine, nil
+ }
+ if len(st.TaskClassIDs) >= 2 {
+ return SchedulePlanGraphNodeDailySplit, nil
+ }
+ return SchedulePlanGraphNodeWeeklyRefine, nil
+}
+
+// normalizeAdjustmentScope 统一把微调力度归一化到 small/medium/large。
+//
+// 调用目的:
+// 1. 旧 scheduleplan 节点逻辑已经大量直接调用这个函数名;
+// 2. 迁到 agent2 后,这里保留同名收口,避免节点层到处散落包前缀;
+// 3. 真正的归一化规则仍以下层 model 层为准,避免多处维护。
+func normalizeAdjustmentScope(raw string) string {
+ return agentmodel.NormalizeSchedulePlanAdjustmentScope(raw)
+}
+
+// schedulePlanIntentOutput 是 plan 节点要求模型返回的结构化结果。
+//
+// 兼容说明:
+// 1. 新主语义是 task_class_ids(数组);
+// 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析;
+// 3. TaskTags 的 key 兼容两种写法:
+// 3.1 推荐:task_item_id(例如 "12");
+// 3.2 兼容:任务名称(例如 "高数复习")。
+type schedulePlanIntentOutput = agentllm.ScheduleIntentOutput
+
+// runPlanNode 负责“识别排程意图 + 提取约束 + 收敛任务类 ID”。
+//
+// 职责边界:
+// 1. 负责把用户自然语言和 extra 参数收敛为统一状态;
+// 2. 负责输出后续节点需要的最小上下文(TaskClassIDs/约束/策略/标签);
+// 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")
+ }
+ st.RestartRequested = false
+ st.AdjustmentReason = ""
+ st.AdjustmentConfidence = 0
+ st.AdjustmentScope = schedulePlanAdjustmentScopeLarge
+
+ emitStage("schedule_plan.plan.analyzing", "正在分析你的排程需求。")
+
+ // 1. 先收敛 extra 中显式传入的任务类 ID(优先级高于模型推断)。
+ // 1.1 先读 task_class_ids 数组;
+ // 1.2 再兼容读取单值 task_class_id;
+ // 1.3 最后统一做过滤 + 去重,防止非法值或重复值污染状态机。
+ if extra != nil {
+ mergedIDs := make([]int, 0, len(st.TaskClassIDs)+2)
+ mergedIDs = append(mergedIDs, st.TaskClassIDs...)
+ if tcIDs, ok := ExtraIntSlice(extra, "task_class_ids"); ok {
+ mergedIDs = append(mergedIDs, tcIDs...)
+ }
+ if tcID, ok := ExtraInt(extra, "task_class_id"); ok && tcID > 0 {
+ mergedIDs = append(mergedIDs, tcID)
+ }
+ st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
+ }
+ // 1.4 若本轮请求没带 task_class_ids,但会话里存在上一次排程快照,则用快照中的任务类兜底。
+ // 1.4.1 这样用户可以直接说“把周三晚上的高数挪到周五”,无需每轮都重复传任务类集合;
+ // 1.4.2 失败兜底:若快照也没有任务类,后续按原逻辑处理(可能提前退出并提示补参)。
+ if len(st.TaskClassIDs) == 0 && len(st.PreviousTaskClassIDs) > 0 {
+ st.TaskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
+ }
+
+ // 2. 识别“是否为连续对话微调”场景。
+ // 2.1 只做历史探测,不做历史改写;
+ // 2.2 探测失败不影响主链路,只是少一个 prompt hint。
+ if st.HasPreviousPreview && len(st.PreviousHybridEntries) > 0 {
+ st.IsAdjustment = true
+ st.AdjustmentScope = schedulePlanAdjustmentScopeMedium
+ }
+ previousPlan := extractPreviousPlanFromHistory(chatHistory)
+ if previousPlan != "" {
+ st.PreviousPlanJSON = previousPlan
+ st.IsAdjustment = true
+ st.AdjustmentScope = schedulePlanAdjustmentScopeMedium
+ }
+
+ // 3. 组装模型提示词。
+ adjustmentHint := ""
+ if st.IsAdjustment {
+ adjustmentHint = "\n注意:这是对已有排程的微调请求,请重点抽取本次新增或变更的约束。"
+ }
+ prompt := fmt.Sprintf(
+ "当前时间(北京时间):%s\n用户输入:%s%s\n\n请提取排程意图与约束。",
+ st.RequestNowText,
+ strings.TrimSpace(userMessage),
+ adjustmentHint,
+ )
+
+ // 4. 调模型拿结构化输出。
+ // 4.1 如果失败但已经有 TaskClassIDs,则降级继续;
+ // 4.2 如果失败且没有任务类 ID,直接给出可执行错误提示。
+ raw, callErr := callScheduleModelForJSON(ctx, chatModel, agentprompt.SchedulePlanIntentPrompt, prompt, 256)
+ if callErr != nil {
+ if len(st.TaskClassIDs) > 0 {
+ st.UserIntent = strings.TrimSpace(userMessage)
+ emitStage("schedule_plan.plan.fallback", "意图识别失败,已使用请求参数兜底继续。")
+ return st, nil
+ }
+ st.FinalSummary = "抱歉,我没拿到有效的任务类信息。请在请求中传入 task_class_ids。"
+ return st, nil
+ }
+
+ parsed, parseErr := parseScheduleJSON[schedulePlanIntentOutput](raw)
+ if parseErr != nil {
+ if len(st.TaskClassIDs) > 0 {
+ st.UserIntent = strings.TrimSpace(userMessage)
+ emitStage("schedule_plan.plan.fallback", "模型返回解析失败,已使用请求参数兜底继续。")
+ return st, nil
+ }
+ st.FinalSummary = "抱歉,我没能解析排程意图。请重试,或直接传入 task_class_ids。"
+ return st, nil
+ }
+
+ // 5. 回填基础字段。
+ st.UserIntent = strings.TrimSpace(parsed.Intent)
+ if st.UserIntent == "" {
+ st.UserIntent = strings.TrimSpace(userMessage)
+ }
+ if len(parsed.Constraints) > 0 {
+ st.Constraints = parsed.Constraints
+ }
+ if strings.EqualFold(strings.TrimSpace(parsed.Strategy), "rapid") {
+ st.Strategy = "rapid"
+ }
+ st.RestartRequested = parsed.Restart
+ st.AdjustmentScope = normalizeAdjustmentScope(parsed.AdjustmentScope)
+ st.AdjustmentReason = strings.TrimSpace(parsed.Reason)
+ st.AdjustmentConfidence = clampAdjustmentConfidence(parsed.Confidence)
+
+ // 5.1 分级语义兜底:
+ // 5.1.1 非微调请求不走 small/medium,强制按 large 进入完整排程;
+ // 5.1.2 微调请求默认至少走 medium,避免 scope 缺失时误判;
+ // 5.1.3 restart=true 时强制重排并清空历史快照承接。
+ if !st.IsAdjustment {
+ st.AdjustmentScope = schedulePlanAdjustmentScopeLarge
+ } else if st.AdjustmentScope == "" {
+ st.AdjustmentScope = schedulePlanAdjustmentScopeMedium
+ }
+ if st.RestartRequested {
+ st.IsAdjustment = false
+ st.AdjustmentScope = schedulePlanAdjustmentScopeLarge
+ clearPreviousPreviewContext(st)
+ }
+
+ // 6. 合并任务类 ID(新字段 + 旧字段双兼容)。
+ // 6.1 先拼接已有值与模型输出;
+ // 6.2 再统一清洗,保证后续节点使用稳定语义。
+ mergedIDs := make([]int, 0, len(st.TaskClassIDs)+len(parsed.TaskClassIDs)+1)
+ mergedIDs = append(mergedIDs, st.TaskClassIDs...)
+ mergedIDs = append(mergedIDs, parsed.TaskClassIDs...)
+ if parsed.TaskClassID > 0 {
+ mergedIDs = append(mergedIDs, parsed.TaskClassID)
+ }
+ st.TaskClassIDs = normalizeTaskClassIDs(mergedIDs)
+
+ // 7. 回填任务标签映射(给 daily_split 注入 context_tag 用)。
+ // 7.1 TaskTags(按 task_item_id)优先;
+ // 7.2 无法转成 ID 的 key 先存到 TaskTagHintsByName,等 roughBuild 阶段再映射;
+ // 7.3 单条标签解析失败不影响主流程。
+ if st.TaskTags == nil {
+ st.TaskTags = make(map[int]string)
+ }
+ if st.TaskTagHintsByName == nil {
+ st.TaskTagHintsByName = make(map[string]string)
+ }
+ for rawKey, rawTag := range parsed.TaskTags {
+ tag := normalizeContextTag(rawTag)
+ key := strings.TrimSpace(rawKey)
+ if key == "" {
+ continue
+ }
+ if id, convErr := strconv.Atoi(key); convErr == nil && id > 0 {
+ st.TaskTags[id] = tag
+ continue
+ }
+ st.TaskTagHintsByName[key] = tag
+ }
+
+ emitStage(
+ "schedule_plan.plan.done",
+ fmt.Sprintf(
+ "已识别排程意图,任务类数量=%d,微调=%t,力度=%s,重排=%t。",
+ len(st.TaskClassIDs),
+ st.IsAdjustment,
+ st.AdjustmentScope,
+ st.RestartRequested,
+ ),
+ )
+ return st, nil
+}
+
+// selectNextAfterPlan 根据 plan 节点结果决定下一步。
+//
+// 分支规则:
+// 1. 如果 FinalSummary 已经有内容,说明已确定要提前退出 -> exit;
+// 2. 如果任务类为空,说明无法继续构建方案 -> exit;
+// 3. 其余情况 -> roughBuild。
+func selectNextAfterPlan(st *SchedulePlanState) string {
+ if st == nil {
+ return schedulePlanGraphNodeExit
+ }
+ if strings.TrimSpace(st.FinalSummary) != "" {
+ return schedulePlanGraphNodeExit
+ }
+ if len(st.TaskClassIDs) == 0 {
+ return schedulePlanGraphNodeExit
+ }
+ return schedulePlanGraphNodeRoughBuild
+}
+
+// runRoughBuildNode 负责“一次性完成粗排结果构建”。
+//
+// 职责边界:
+// 1. 调用多任务类混排能力,生成 HybridEntries + AllocatedItems;
+// 2. 把 HybridEntries 转成 CandidatePlans,便于后续预览输出;
+// 3. 不做 daily/weekly 优化本身,只提供下游输入。
+func runRoughBuildNode(
+ 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 roughBuild node")
+ }
+ if deps.HybridScheduleWithPlanMulti == nil {
+ return nil, errors.New("schedule plan graph: HybridScheduleWithPlanMulti dependency not injected")
+ }
+
+ // 1. 清洗并校验任务类 ID。
+ // 1.1 统一在节点入口做一次最终收敛,避免上游遗漏导致语义漂移;
+ // 1.2 若最终仍为空,直接结束,避免无意义调用下游服务。
+ taskClassIDs := normalizeTaskClassIDs(st.TaskClassIDs)
+ // 1.3 连续对话兜底:若本轮任务类为空且命中历史快照,则回退到上轮任务类集合。
+ if len(taskClassIDs) == 0 && st.IsAdjustment && len(st.PreviousTaskClassIDs) > 0 {
+ taskClassIDs = normalizeTaskClassIDs(append([]int(nil), st.PreviousTaskClassIDs...))
+ }
+ if len(taskClassIDs) == 0 {
+ st.FinalSummary = "缺少有效的任务类 ID,无法生成排程方案。请传入 task_class_ids。"
+ return st, nil
+ }
+ st.TaskClassIDs = taskClassIDs
+
+ // 2. 连续对话微调优先复用上一版混合日程作为起点,避免“每轮都重新粗排”。
+ // 2.1 触发条件:IsAdjustment=true 且 PreviousHybridEntries 非空;
+ // 2.2 失败兜底:若快照不完整(例如 AllocatedItems 为空),会构造最小占位任务块,保持下游校验可运行;
+ // 2.3 回退策略:若没有可复用快照,再走全量粗排构建路径。
+ canReusePreviousPlan := st.IsAdjustment &&
+ !st.RestartRequested &&
+ len(st.PreviousHybridEntries) > 0 &&
+ sameTaskClassSet(taskClassIDs, st.PreviousTaskClassIDs)
+ if canReusePreviousPlan {
+ emitStage("schedule_plan.rough_build.reuse_previous", "检测到连续对话微调,复用上一版排程作为优化起点。")
+ st.HybridEntries = deepCopyEntries(st.PreviousHybridEntries)
+ st.CandidatePlans = deepCopyWeekSchedules(st.PreviousCandidatePlans)
+ if len(st.CandidatePlans) == 0 {
+ st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
+ }
+ st.AllocatedItems = deepCopyTaskClassItems(st.PreviousAllocatedItems)
+ if len(st.AllocatedItems) == 0 {
+ st.AllocatedItems = buildAllocatedItemsFromHybridEntries(st.HybridEntries)
+ }
+
+ // 2.2 复用模式下同样尝试解析窗口边界,保证周级 Move 约束仍然有效。
+ if deps.ResolvePlanningWindow != nil {
+ startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
+ if windowErr != nil {
+ st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
+ return st, nil
+ }
+ st.HasPlanningWindow = true
+ st.PlanStartWeek = startWeek
+ st.PlanStartDay = startDay
+ st.PlanEndWeek = endWeek
+ st.PlanEndDay = endDay
+ }
+
+ st.MergeSnapshot = deepCopyEntries(st.HybridEntries)
+ suggestedCount := 0
+ for _, e := range st.HybridEntries {
+ if e.Status == "suggested" {
+ suggestedCount++
+ }
+ }
+ emitStage(
+ "schedule_plan.rough_build.done",
+ fmt.Sprintf("已复用历史方案,条目总数=%d,可优化条目=%d。", len(st.HybridEntries), suggestedCount),
+ )
+ return st, nil
+ }
+
+ emitStage("schedule_plan.rough_build.building", "正在构建粗排候选方案。")
+
+ // 3. 调用服务层统一能力构建混合日程。
+ // 3.1 该能力内部会完成“多任务类粗排 + 既有日程合并”;
+ // 3.2 这里不再拆成 preview/hybrid 两段,避免跨节点重复计算。
+ entries, allocatedItems, err := deps.HybridScheduleWithPlanMulti(ctx, st.UserID, taskClassIDs)
+ if err != nil {
+ st.FinalSummary = fmt.Sprintf("构建粗排方案失败:%s。", err.Error())
+ return st, nil
+ }
+ if len(entries) == 0 {
+ st.FinalSummary = "没有生成可优化的排程条目,请检查任务类时间范围或课表占用。"
+ return st, nil
+ }
+
+ // 4. 回填状态。
+ st.HybridEntries = entries
+ st.AllocatedItems = allocatedItems
+ st.CandidatePlans = hybridEntriesToWeekSchedules(entries)
+
+ // 4.1 解析全局排程窗口(可选依赖)。
+ // 4.1.1 目的:给周级 Move 增加“首尾不足一周”的硬边界校验;
+ // 4.1.2 失败策略:若依赖已注入但解析失败,直接结束本次排程,避免带着错误窗口继续优化。
+ if deps.ResolvePlanningWindow != nil {
+ startWeek, startDay, endWeek, endDay, windowErr := deps.ResolvePlanningWindow(ctx, st.UserID, taskClassIDs)
+ if windowErr != nil {
+ st.FinalSummary = fmt.Sprintf("解析排程窗口失败:%s。", windowErr.Error())
+ return st, nil
+ }
+ st.HasPlanningWindow = true
+ st.PlanStartWeek = startWeek
+ st.PlanStartDay = startDay
+ st.PlanEndWeek = endWeek
+ st.PlanEndDay = endDay
+ }
+
+ // 4.2 记录 merge 快照:
+ // 4.2.1 单任务类路径可直接作为 final_check 回退基线;
+ // 4.2.2 多任务类路径后续 merge 节点会覆盖成“日内优化后快照”。
+ st.MergeSnapshot = deepCopyEntries(entries)
+
+ // 5. 把“按名称提示的标签”尽可能映射到 task_item_id。
+ // 5.1 目的:后续 daily_split 统一按 task_item_id 维度写入 context_tag;
+ // 5.2 失败策略:映射不上不报错,后续默认走 General 标签。
+ if st.TaskTags == nil {
+ st.TaskTags = make(map[int]string)
+ }
+ if len(st.TaskTagHintsByName) > 0 {
+ for i := range st.HybridEntries {
+ entry := &st.HybridEntries[i]
+ if entry.Status != "suggested" || entry.TaskItemID <= 0 {
+ continue
+ }
+ if _, exists := st.TaskTags[entry.TaskItemID]; exists {
+ continue
+ }
+ if tag, ok := st.TaskTagHintsByName[entry.Name]; ok {
+ st.TaskTags[entry.TaskItemID] = normalizeContextTag(tag)
+ }
+ }
+ }
+
+ suggestedCount := 0
+ for _, e := range entries {
+ if e.Status == "suggested" {
+ suggestedCount++
+ }
+ }
+ emitStage(
+ "schedule_plan.rough_build.done",
+ fmt.Sprintf("粗排构建完成,条目总数=%d,可优化条目=%d。", len(entries), suggestedCount),
+ )
+ return st, nil
+}
+
+// callScheduleModelForJSON 调用模型并要求返回 JSON。
+//
+// 职责边界:
+// 1. 仅负责模型调用参数装配,不做业务字段解释;
+// 2. 统一关闭 thinking,减少路由/抽取场景的延迟和 token 开销。
+func callScheduleModelForJSON(ctx context.Context, chatModel *ark.ChatModel, systemPrompt, userPrompt string, maxTokens int) (string, error) {
+ return agentllm.CallArkText(ctx, chatModel, systemPrompt, userPrompt, agentllm.ArkCallOptions{
+ Temperature: 0,
+ MaxTokens: maxTokens,
+ Thinking: agentllm.ThinkingModeDisabled,
+ })
+}
+
+// parseScheduleJSON 解析模型返回的 JSON 内容。
+//
+// 兼容策略:
+// 1. 兼容 ```json ... ``` 包裹;
+// 2. 兼容模型在 JSON 前后带解释文本(提取最外层对象)。
+func parseScheduleJSON[T any](raw string) (*T, error) {
+ return agentllm.ParseJSONObject[T](raw)
+}
+
+// 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 ""
+}
+
+// runReturnPreviewNode 负责把优化后的 HybridEntries 转成“前端可直接展示”的预览结构。
+//
+// 职责边界:
+// 1. 把 suggested 结果回填到 AllocatedItems,便于后续确认后直接落库;
+// 2. 生成 CandidatePlans;
+// 3. 生成最终文案;
+// 4. 不执行实际写库。
+func runReturnPreviewNode(
+ ctx context.Context,
+ st *SchedulePlanState,
+ emitStage func(stage, detail string),
+) (*SchedulePlanState, error) {
+ _ = ctx
+ 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。
+ 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. 生成前端预览结构。
+ st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
+
+ // 3. 生成最终摘要:
+ // 3.1 优先保留 final_check 的输出;
+ // 3.2 若没有 final_check 输出,则回退 weekly refine 摘要;
+ // 3.3 都没有时给兜底文案。
+ if strings.TrimSpace(st.FinalSummary) == "" {
+ 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
+}
+
+// buildAllocatedItemsFromHybridEntries 根据 suggested 条目构造最小可用的任务块快照。
+//
+// 设计目的:
+// 1. 连续微调复用历史方案时,若缓存里没有 AllocatedItems,仍然保证 final_check 的数量核对可运行;
+// 2. return_preview 仍可依据 TaskItemID 回填最终 embedded_time;
+// 3. 该函数只做“兜底构造”,不替代真实粗排输出。
+func buildAllocatedItemsFromHybridEntries(entries []model.HybridScheduleEntry) []model.TaskClassItem {
+ if len(entries) == 0 {
+ return nil
+ }
+ items := make([]model.TaskClassItem, 0)
+ for _, entry := range entries {
+ if entry.Status != "suggested" {
+ continue
+ }
+ embedded := &model.TargetTime{
+ Week: entry.Week,
+ DayOfWeek: entry.DayOfWeek,
+ SectionFrom: entry.SectionFrom,
+ SectionTo: entry.SectionTo,
+ }
+ taskID := entry.TaskItemID
+ items = append(items, model.TaskClassItem{
+ ID: taskID,
+ EmbeddedTime: embedded,
+ })
+ }
+ return items
+}
+
+// deepCopyTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨节点共享引用。
+func deepCopyTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
+ return agentshared.CloneTaskClassItems(src)
+}
+
+// normalizeContextTag 归一化任务标签。
+//
+// 失败兜底:
+// 1. 未识别/空值统一回落到 General;
+// 2. 保证后续 prompt 构造不会出现空标签。
+func normalizeContextTag(raw string) string {
+ tag := strings.TrimSpace(raw)
+ if tag == "" {
+ return "General"
+ }
+ switch strings.ToLower(tag) {
+ case "high-logic", "high_logic", "logic":
+ return "High-Logic"
+ case "memory":
+ return "Memory"
+ case "review":
+ return "Review"
+ case "general":
+ return "General"
+ default:
+ return "General"
+ }
+}
+
+// normalizeTaskClassIDs 清洗 task_class_ids(去重 + 过滤非法值)。
+func normalizeTaskClassIDs(ids []int) []int {
+ if len(ids) == 0 {
+ return nil
+ }
+ seen := make(map[int]struct{}, len(ids))
+ out := make([]int, 0, len(ids))
+ for _, id := range ids {
+ if id <= 0 {
+ continue
+ }
+ if _, exists := seen[id]; exists {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ return out
+}
+
+// clearPreviousPreviewContext 清空会话承接快照字段。
+//
+// 触发场景:
+// 1. 用户明确要求 restart(重新排);
+// 2. 需要强制断开“沿用历史方案”的路径,避免脏状态渗透到新方案。
+func clearPreviousPreviewContext(st *SchedulePlanState) {
+ if st == nil {
+ return
+ }
+ st.HasPreviousPreview = false
+ st.PreviousTaskClassIDs = nil
+ st.PreviousHybridEntries = nil
+ st.PreviousAllocatedItems = nil
+ st.PreviousCandidatePlans = nil
+ st.PreviousPlanJSON = ""
+}
+
+// clampAdjustmentConfidence 约束置信度字段到 [0,1]。
+func clampAdjustmentConfidence(v float64) float64 {
+ if v < 0 {
+ return 0
+ }
+ if v > 1 {
+ return 1
+ }
+ return v
+}
+
+// deepCopyWeekSchedules 深拷贝周视图方案切片,避免跨节点共享引用。
+func deepCopyWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
+ return agentshared.CloneWeekSchedules(src)
+}
+
+// sameTaskClassSet 判断两组 task_class_ids 是否表示同一集合(忽略顺序,忽略重复)。
+//
+// 语义:
+// 1. 两边经清洗后都为空,返回 false(空集合不作为“可复用历史方案”的依据);
+// 2. 元素集合完全一致返回 true;
+// 3. 任一元素差异返回 false。
+func sameTaskClassSet(left []int, right []int) bool {
+ l := normalizeTaskClassIDs(left)
+ r := normalizeTaskClassIDs(right)
+ if len(l) == 0 || len(r) == 0 {
+ return false
+ }
+ if len(l) != len(r) {
+ return false
+ }
+ seen := make(map[int]struct{}, len(l))
+ for _, id := range l {
+ seen[id] = struct{}{}
+ }
+ for _, id := range r {
+ if _, ok := seen[id]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+// hybridEntriesToWeekSchedules 把内存中的混合条目转换成前端周视图格式。
+func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule {
+ 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 week, events := range weekMap {
+ result = append(result, model.UserWeekSchedule{
+ Week: week,
+ 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
+}
+
+// runDailySplitNode 负责“按天拆分 + 标签注入 + 跳过判断”。
+//
+// 职责边界:
+// 1. 负责把全量 HybridEntries 拆成 DayGroup,供后续并发日内优化;
+// 2. 负责把 TaskTags(task_item_id -> tag) 注入到条目的 ContextTag;
+// 3. 负责识别“低收益天”(suggested<=2)并标记 SkipRefine;
+// 4. 不负责调用模型,不负责并发执行,不负责结果合并。
+func runDailySplitNode(
+ ctx context.Context,
+ st *SchedulePlanState,
+ emitStage func(stage, detail string),
+) (*SchedulePlanState, error) {
+ _ = ctx
+ if st == nil || len(st.HybridEntries) == 0 {
+ return st, nil
+ }
+
+ emitStage("schedule_plan.daily_split.start", "正在按天拆分排程并标记优化单元。")
+
+ // 1. 初始化容器:
+ // 1.1 groups 以 week/day 二级索引保存 DayGroup;
+ // 1.2 这么做的目的是后续 daily_refine 可以直接并发遍历,不再重复分组。
+ groups := make(map[int]map[int]*DayGroup)
+
+ // 2. 遍历混合条目,执行“标签注入 + 分组”。
+ for i := range st.HybridEntries {
+ entry := &st.HybridEntries[i]
+
+ // 2.1 仅对 suggested 条目注入 ContextTag。
+ // 2.1.1 existing 条目是固定课表/已落库任务,不参与认知标签优化。
+ // 2.1.2 注入失败时兜底 General,避免后续 prompt 出现空标签。
+ if entry.Status == "suggested" && entry.TaskItemID > 0 {
+ if tag, ok := st.TaskTags[entry.TaskItemID]; ok {
+ entry.ContextTag = normalizeContextTag(tag)
+ } else {
+ entry.ContextTag = "General"
+ }
+ }
+
+ // 2.2 建立分组索引。
+ if groups[entry.Week] == nil {
+ groups[entry.Week] = make(map[int]*DayGroup)
+ }
+ if groups[entry.Week][entry.DayOfWeek] == nil {
+ groups[entry.Week][entry.DayOfWeek] = &DayGroup{
+ Week: entry.Week,
+ DayOfWeek: entry.DayOfWeek,
+ }
+ }
+ groups[entry.Week][entry.DayOfWeek].Entries = append(groups[entry.Week][entry.DayOfWeek].Entries, *entry)
+ }
+
+ // 3. 逐天计算 suggested 数量,标记是否跳过日内优化。
+ //
+ // 3.1 为什么阈值设为 <=2:
+ // 3.1.1 suggested 很少时,模型优化收益通常不足以覆盖请求成本;
+ // 3.1.2 直接跳过可减少无效模型调用和阶段等待。
+ // 3.2 失败策略:
+ // 3.2.1 这里只做内存标记,不会失败;
+ // 3.2.2 即使阈值判断不完美,也只影响优化深度,不影响功能正确性。
+ totalDays := 0
+ skipDays := 0
+ for _, dayMap := range groups {
+ for _, dayGroup := range dayMap {
+ totalDays++
+ suggestedCount := 0
+ for _, e := range dayGroup.Entries {
+ if e.Status == "suggested" {
+ suggestedCount++
+ }
+ }
+ if suggestedCount <= 2 {
+ dayGroup.SkipRefine = true
+ skipDays++
+ }
+ }
+ }
+
+ // 4. 回填状态,交给后续节点使用。
+ st.DailyGroups = groups
+ emitStage(
+ "schedule_plan.daily_split.done",
+ fmt.Sprintf("已拆分为 %d 天,其中 %d 天跳过日内优化。", totalDays, skipDays),
+ )
+ return st, nil
+}
+
+// runQuickRefineNode 是 small 微调分支的“轻量预算收缩节点”。
+//
+// 职责边界:
+// 1. 负责在进入 weekly_refine 前收缩预算与并发,避免小改动走重链路;
+// 2. 负责保留“可回退”的最低预算,避免直接压成 0 导致无动作可执行;
+// 3. 不负责执行任何 Move/Swap(真正动作仍由 weekly_refine 完成)。
+func runQuickRefineNode(
+ ctx context.Context,
+ st *SchedulePlanState,
+ emitStage func(stage, detail string),
+) (*SchedulePlanState, error) {
+ _ = ctx
+ if st == nil {
+ return nil, fmt.Errorf("schedule plan quick refine: nil state")
+ }
+
+ emitStage("schedule_plan.quick_refine.start", "检测到小幅微调,正在切换到快速优化路径。")
+
+ // 1. 预算收缩策略:
+ // 1.1 small 场景目标是“快速响应 + 可解释改动”,不追求大规模重排;
+ // 1.2 因此把总预算压到最多 2 次尝试、有效预算压到最多 1 次成功动作;
+ // 1.3 如果上游已配置更小预算,则尊重更小值,不做反向放大。
+ if st.WeeklyTotalBudget <= 0 {
+ st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget
+ }
+ if st.WeeklyAdjustBudget <= 0 {
+ st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget
+ }
+ st.WeeklyTotalBudget = clampBudgetUpper(st.WeeklyTotalBudget, 2)
+ st.WeeklyAdjustBudget = clampBudgetUpper(st.WeeklyAdjustBudget, 1)
+
+ // 2. 预算一致性兜底:
+ // 2.1 总预算至少为 1(否则 weekly worker 无法执行);
+ // 2.2 有效预算至少为 1(否则所有成功动作都不被允许);
+ // 2.3 有效预算永远不能超过总预算。
+ if st.WeeklyTotalBudget < 1 {
+ st.WeeklyTotalBudget = 1
+ }
+ if st.WeeklyAdjustBudget < 1 {
+ st.WeeklyAdjustBudget = 1
+ }
+ if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
+ st.WeeklyAdjustBudget = st.WeeklyTotalBudget
+ }
+
+ // 3. 小改动路径把周级并发收敛到 1,优先保证稳定与可观察性。
+ st.WeeklyRefineConcurrency = 1
+
+ emitStage(
+ "schedule_plan.quick_refine.done",
+ fmt.Sprintf(
+ "快速微调预算已生效:总预算=%d,有效预算=%d,并发=%d。",
+ st.WeeklyTotalBudget,
+ st.WeeklyAdjustBudget,
+ st.WeeklyRefineConcurrency,
+ ),
+ )
+ return st, nil
+}
+
+// clampBudgetUpper 把预算裁剪到“非负且不超过上限”。
+func clampBudgetUpper(current int, upper int) int {
+ if current < 0 {
+ return 0
+ }
+ if current > upper {
+ return upper
+ }
+ return current
+}
+
+const (
+ // dailyReactRoundTimeout 是日内单轮模型调用超时。
+ // 日内节点走并发调用,超时要比周级更保守,避免占满资源。
+ dailyReactRoundTimeout = 3 * time.Minute
+)
+
+// runDailyRefineNode 负责“并发日内优化”。
+//
+// 职责边界:
+// 1. 负责按 DayGroup 并发调用单日 ReAct;
+// 2. 负责输出“按天开始/完成”的阶段状态块(不推 reasoning 细流);
+// 3. 负责把单日失败回退到原始数据,确保全链路可继续;
+// 4. 不负责跨天配平(交给 weekly_refine),不负责最终总结(交给 final_check)。
+func runDailyRefineNode(
+ ctx context.Context,
+ st *SchedulePlanState,
+ chatModel *ark.ChatModel,
+ dailyRefineConcurrency int,
+ emitStage func(stage, detail string),
+) (*SchedulePlanState, error) {
+ if st == nil || len(st.DailyGroups) == 0 {
+ return st, nil
+ }
+ if chatModel == nil {
+ return st, fmt.Errorf("schedule plan daily refine: model is nil")
+ }
+
+ // 1. 并发度兜底:
+ // 1.1 优先使用注入参数;
+ // 1.2 若注入参数非法,则回退到 state 值;
+ // 1.3 state 也非法时,回退到编译期默认值。
+ if dailyRefineConcurrency <= 0 {
+ dailyRefineConcurrency = st.DailyRefineConcurrency
+ }
+ if dailyRefineConcurrency <= 0 {
+ dailyRefineConcurrency = schedulePlanDefaultDailyRefineConcurrency
+ }
+
+ emitStage(
+ "schedule_plan.daily_refine.start",
+ fmt.Sprintf("正在并发优化各天日程,并发度=%d。", dailyRefineConcurrency),
+ )
+
+ // 2. 拉平所有 DayGroup 并排序,确保日志与阶段输出稳定可读。
+ allGroups := flattenAndSortDayGroups(st.DailyGroups)
+ if len(allGroups) == 0 {
+ st.DailyResults = make(map[int]map[int][]model.HybridScheduleEntry)
+ emitStage("schedule_plan.daily_refine.done", "没有可优化的天,跳过日内优化。")
+ return st, nil
+ }
+
+ // 3. 并发执行:
+ // 3.1 sem 控制并发上限;
+ // 3.2 wg 等待全部 goroutine 完成;
+ // 3.3 mu 保护 results/firstErr,避免竞态。
+ sem := make(chan struct{}, dailyRefineConcurrency)
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+ totalGroups := int32(len(allGroups))
+ var finishedGroups int32
+
+ results := make(map[int]map[int][]model.HybridScheduleEntry)
+ var firstErr error
+
+ for _, group := range allGroups {
+ g := group
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ // 3.4 先申请并发令牌;若 ctx 已取消,直接回退原始数据并结束。
+ select {
+ case sem <- struct{}{}:
+ defer func() { <-sem }()
+ case <-ctx.Done():
+ mu.Lock()
+ if firstErr == nil {
+ firstErr = ctx.Err()
+ }
+ ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
+ mu.Unlock()
+ // 3.4.1 取消场景也要计入进度,避免前端看到“卡住不动”。
+ done := atomic.AddInt32(&finishedGroups, 1)
+ emitStage(
+ "schedule_plan.daily_refine.day_done",
+ fmt.Sprintf("W%dD%d 已取消并回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
+ )
+ return
+ }
+
+ emitStage(
+ "schedule_plan.daily_refine.day_start",
+ fmt.Sprintf("正在安排 W%dD%d。(当前进度 %d/%d)", g.Week, g.DayOfWeek, atomic.LoadInt32(&finishedGroups), totalGroups),
+ )
+
+ // 3.5 低收益天直接跳过模型调用,原样透传。
+ if g.SkipRefine {
+ mu.Lock()
+ ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
+ mu.Unlock()
+ done := atomic.AddInt32(&finishedGroups, 1)
+ emitStage(
+ "schedule_plan.daily_refine.day_done",
+ fmt.Sprintf("W%dD%d suggested 较少,已跳过优化。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
+ )
+ return
+ }
+
+ // 3.6 深拷贝输入,避免并发场景下意外修改共享切片。
+ localEntries := deepCopyEntries(g.Entries)
+
+ // 3.7 动态轮次:
+ // 3.7.1 suggested <= 4:1轮足够;
+ // 3.7.2 suggested > 4:最多2轮,提升复杂天优化质量。
+ maxRounds := 1
+ if countSuggested(localEntries) > 4 {
+ maxRounds = 2
+ }
+
+ optimized, refineErr := runSingleDayReact(ctx, chatModel, localEntries, maxRounds, g.Week, g.DayOfWeek)
+ if refineErr != nil {
+ mu.Lock()
+ if firstErr == nil {
+ firstErr = refineErr
+ }
+ // 3.8 单天失败回退:
+ // 3.8.1 保证失败只影响该天;
+ // 3.8.2 保证总流程可继续推进到 merge/weekly/final。
+ ensureDayResult(results, g.Week, g.DayOfWeek, g.Entries)
+ mu.Unlock()
+ done := atomic.AddInt32(&finishedGroups, 1)
+ emitStage(
+ "schedule_plan.daily_refine.day_done",
+ fmt.Sprintf("W%dD%d 优化失败,已回退原方案。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
+ )
+ return
+ }
+
+ mu.Lock()
+ ensureDayResult(results, g.Week, g.DayOfWeek, optimized)
+ mu.Unlock()
+ done := atomic.AddInt32(&finishedGroups, 1)
+ emitStage(
+ "schedule_plan.daily_refine.day_done",
+ fmt.Sprintf("W%dD%d 已安排完成。(进度 %d/%d)", g.Week, g.DayOfWeek, done, totalGroups),
+ )
+ }()
+ }
+
+ wg.Wait()
+ st.DailyResults = results
+ if firstErr != nil {
+ emitStage("schedule_plan.daily_refine.partial_error", fmt.Sprintf("部分天优化失败,已自动回退。原因:%s", firstErr.Error()))
+ }
+ emitStage("schedule_plan.daily_refine.done", "日内优化阶段完成。")
+ return st, nil
+}
+
+// runSingleDayReact 执行单天封闭式 ReAct 优化。
+//
+// 关键约束:
+// 1. prompt 只包含当天数据;
+// 2. 代码层再做“Move 不能跨天”硬校验;
+// 3. Thinking 默认关闭,优先降低日内并发阶段的长尾时延。
+func runSingleDayReact(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ entries []model.HybridScheduleEntry,
+ maxRounds int,
+ week int,
+ dayOfWeek int,
+) ([]model.HybridScheduleEntry, error) {
+ hybridJSON, err := json.Marshal(entries)
+ if err != nil {
+ return entries, err
+ }
+
+ messages := []*schema.Message{
+ schema.SystemMessage(agentprompt.SchedulePlanDailyReactPrompt),
+ schema.UserMessage(fmt.Sprintf(
+ "以下是今天的日程(JSON):\n%s\n\n仅优化这一天的数据,不要跨天移动。",
+ string(hybridJSON),
+ )),
+ }
+
+ for round := 0; round < maxRounds; round++ {
+ roundCtx, cancel := context.WithTimeout(ctx, dailyReactRoundTimeout)
+ // 1. 日内优化只做“单天局部微调”,任务边界清晰,默认关闭 thinking 以降低时延。
+ // 2. 周级全局配平仍保留 thinking(在 weekly_refine),这里不承担跨天复杂推理职责。
+ // 3. 模型调用细节统一下沉到 llm 层,避免 schedule skill 再维护一份 SDK 样板。
+ content, generateErr := agentllm.GenerateScheduleDailyReactRound(roundCtx, chatModel, messages)
+ cancel()
+ if generateErr != nil {
+ return entries, fmt.Errorf("日内 ReAct 第%d轮失败: %w", round+1, generateErr)
+ }
+ parsed, parseErr := parseReactLLMOutput(content)
+ if parseErr != nil {
+ // 解析失败时回退当前轮,不把异常向上放大成整条链路失败。
+ return entries, nil
+ }
+ if parsed.Done || len(parsed.ToolCalls) == 0 {
+ break
+ }
+
+ // 1. 执行工具调用。
+ // 1.1 每个调用都经过“日内策略约束”校验;
+ // 1.2 任何单次调用失败都只返回 failed result,不中断整轮。
+ results := make([]reactToolResult, 0, len(parsed.ToolCalls))
+ for _, call := range parsed.ToolCalls {
+ var result reactToolResult
+ entries, result = dispatchDailyReactTool(entries, call, week, dayOfWeek)
+ results = append(results, result)
+ }
+
+ // 2. 把“本轮模型输出 + 工具执行结果”拼入下一轮上下文。
+ // 2.1 这样模型可以看到操作反馈,继续迭代;
+ // 2.2 若下一轮仍无有效动作,会自然在 done/空 tool_calls 退出。
+ messages = append(messages, schema.AssistantMessage(content, nil))
+ resultJSON, _ := json.Marshal(results)
+ messages = append(messages, schema.UserMessage(
+ fmt.Sprintf("工具执行结果:\n%s\n\n请继续优化或输出 {\"done\":true,\"summary\":\"...\"}。", string(resultJSON)),
+ ))
+ }
+
+ return entries, nil
+}
+
+// dispatchDailyReactTool 在通用工具分发前增加“日内硬约束”。
+//
+// 职责边界:
+// 1. 只负责校验 Move 的目标是否仍在当前天;
+// 2. 通过后复用 dispatchReactTool 执行;
+// 3. 不负责复杂冲突判定(冲突判定由底层工具函数处理)。
+func dispatchDailyReactTool(entries []model.HybridScheduleEntry, call reactToolCall, week int, dayOfWeek int) ([]model.HybridScheduleEntry, reactToolResult) {
+ if call.Tool == "Move" {
+ toWeek, weekOK := paramInt(call.Params, "to_week")
+ toDay, dayOK := paramInt(call.Params, "to_day")
+ if !weekOK || !dayOK {
+ return entries, reactToolResult{
+ Tool: "Move",
+ Success: false,
+ Result: "参数缺失:to_week/to_day",
+ }
+ }
+ if toWeek != week || toDay != dayOfWeek {
+ return entries, reactToolResult{
+ Tool: "Move",
+ Success: false,
+ Result: fmt.Sprintf("日内优化禁止跨天移动:当前仅允许 W%dD%d", week, dayOfWeek),
+ }
+ }
+ }
+ return dispatchReactTool(entries, call)
+}
+
+// flattenAndSortDayGroups 把 map 结构摊平成有序切片,便于稳定并发调度。
+func flattenAndSortDayGroups(groups map[int]map[int]*DayGroup) []*DayGroup {
+ out := make([]*DayGroup, 0)
+ for _, dayMap := range groups {
+ for _, g := range dayMap {
+ if g != nil {
+ out = append(out, g)
+ }
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Week != out[j].Week {
+ return out[i].Week < out[j].Week
+ }
+ return out[i].DayOfWeek < out[j].DayOfWeek
+ })
+ return out
+}
+
+// ensureDayResult 确保 results[week][day] 存在并写入值。
+func ensureDayResult(results map[int]map[int][]model.HybridScheduleEntry, week int, day int, entries []model.HybridScheduleEntry) {
+ if results[week] == nil {
+ results[week] = make(map[int][]model.HybridScheduleEntry)
+ }
+ results[week][day] = entries
+}
+
+// deepCopyEntries 深拷贝 HybridScheduleEntry 切片。
+func deepCopyEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
+ dst := make([]model.HybridScheduleEntry, len(src))
+ copy(dst, src)
+ return dst
+}
+
+// runMergeNode 负责“合并日内结果 + 冲突校验 + 回退快照”。
+//
+// 职责边界:
+// 1. 负责把 DailyResults 合并回全量 HybridEntries;
+// 2. 负责执行时间冲突检测;
+// 3. 负责在冲突时回退原始数据;
+// 4. 负责产出 MergeSnapshot,供 final_check 失败时回退。
+func runMergeNode(
+ ctx context.Context,
+ st *SchedulePlanState,
+ emitStage func(stage, detail string),
+) (*SchedulePlanState, error) {
+ _ = ctx
+ if st == nil || len(st.DailyResults) == 0 {
+ return st, nil
+ }
+
+ emitStage("schedule_plan.merge.start", "正在合并日内优化结果。")
+
+ // 1. 先保存 merge 前原始数据,作为冲突时的第一层回退兜底。
+ originalEntries := deepCopyEntries(st.HybridEntries)
+
+ // 2. 展平 daily results。
+ merged := make([]model.HybridScheduleEntry, 0)
+ for _, dayMap := range st.DailyResults {
+ for _, dayEntries := range dayMap {
+ merged = append(merged, dayEntries...)
+ }
+ }
+
+ // 3. 冲突校验。
+ //
+ // 3.1 判断依据:同一 (week, day, section) 只能有一个条目占用;
+ // 3.2 失败处理:一旦冲突,整批回退到 merge 前原始结果;
+ // 3.3 回退策略:回退后仍继续链路,避免请求直接失败。
+ if conflict := detectConflicts(merged); conflict != "" {
+ st.HybridEntries = originalEntries
+ emitStage("schedule_plan.merge.conflict", fmt.Sprintf("检测到冲突并回退:%s", conflict))
+ } else {
+ st.HybridEntries = merged
+ emitStage("schedule_plan.merge.done", fmt.Sprintf("合并完成,共 %d 个条目。", len(merged)))
+ }
+
+ // 4. 无论是否冲突,都生成“可回退快照”。
+ st.MergeSnapshot = deepCopyEntries(st.HybridEntries)
+ return st, nil
+}
+
+// detectConflicts 检测条目是否存在时间冲突。
+//
+// 返回语义:
+// 1. 返回空字符串:无冲突;
+// 2. 返回非空字符串:冲突描述,可直接用于日志/阶段提示。
+func detectConflicts(entries []model.HybridScheduleEntry) string {
+ type slotKey struct {
+ week, day, section int
+ }
+ occupied := make(map[slotKey]string)
+ for _, entry := range entries {
+ // 1. 仅“阻塞建议任务”的条目参与冲突校验。
+ // 2. 可嵌入且当前未占用的课程槽位不应被判定为冲突。
+ if !entryBlocksSuggested(entry) {
+ continue
+ }
+ for section := entry.SectionFrom; section <= entry.SectionTo; section++ {
+ key := slotKey{week: entry.Week, day: entry.DayOfWeek, section: section}
+ if prevName, exists := occupied[key]; exists {
+ return fmt.Sprintf(
+ "W%dD%d 第%d节 冲突:[%s] 与 [%s]",
+ entry.Week, entry.DayOfWeek, section, prevName, entry.Name,
+ )
+ }
+ occupied[key] = entry.Name
+ }
+ }
+ return ""
+}
+
+const (
+ // weeklyReactRoundTimeout 是周级“单步动作”单轮超时时间。
+ //
+ // 说明:
+ // 1. 当前周级策略是“每轮只做一个动作”,单轮输入较短,超时可比旧版更保守;
+ // 2. 过长超时会放大长尾等待,影响并发周优化的整体收口速度。
+ weeklyReactRoundTimeout = 4 * time.Minute
+)
+
+// weeklyRefineWorkerResult 是“单周 worker”输出。
+//
+// 职责边界:
+// 1. 记录该周优化后的 entries;
+// 2. 记录预算消耗(总动作/有效动作);
+// 3. 记录动作日志,供 final_check 生成“过程可解释”总结;
+// 4. 记录该周摘要,便于最终汇总。
+type weeklyRefineWorkerResult struct {
+ Week int
+ Entries []model.HybridScheduleEntry
+ TotalUsed int
+ EffectiveUsed int
+ Summary string
+ ActionLogs []string
+}
+
+// runWeeklyRefineNode 执行“周级单步优化”。
+//
+// 新链路目标:
+// 1. 把全量周数据拆成“按周并发”执行,降低单次模型输入规模;
+// 2. 每轮只允许一个动作(Move/Swap)或 done,减少模型犹豫;
+// 3. 使用“双预算”约束迭代:
+// 3.1 总动作预算:成功/失败都扣减;
+// 3.2 有效动作预算:仅成功动作扣减;
+// 4. 不在该阶段输出 reasoning 文本,改为阶段状态 + 动作结果,避免刷屏。
+func runWeeklyRefineNode(
+ ctx context.Context,
+ st *SchedulePlanState,
+ chatModel *ark.ChatModel,
+ outChan chan<- string,
+ modelName string,
+ emitStage func(stage, detail string),
+) (*SchedulePlanState, error) {
+ _ = outChan
+ if st == nil {
+ return nil, fmt.Errorf("schedule plan weekly refine: nil state")
+ }
+ if chatModel == nil {
+ return nil, fmt.Errorf("schedule plan weekly refine: model is nil")
+ }
+ if len(st.HybridEntries) == 0 {
+ st.ReactDone = true
+ st.ReactSummary = "无可优化的排程条目。"
+ return st, nil
+ }
+ if strings.TrimSpace(modelName) == "" {
+ modelName = "worker"
+ }
+
+ // 1. 预算与并发兜底。
+ // 1.1 有效预算(旧字段)<=0 时回退默认值;
+ // 1.2 总预算 <=0 时回退默认值;
+ // 1.3 为避免“有效预算 > 总预算”的反直觉状态,做一次归一化修正;
+ // 1.4 周级并发度默认不高于周数,避免空并发浪费。
+ if st.WeeklyAdjustBudget <= 0 {
+ st.WeeklyAdjustBudget = schedulePlanDefaultWeeklyAdjustBudget
+ }
+ if st.WeeklyTotalBudget <= 0 {
+ st.WeeklyTotalBudget = schedulePlanDefaultWeeklyTotalBudget
+ }
+ if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
+ st.WeeklyAdjustBudget = st.WeeklyTotalBudget
+ }
+ if st.WeeklyRefineConcurrency <= 0 {
+ st.WeeklyRefineConcurrency = schedulePlanDefaultWeeklyRefineConcurrency
+ }
+
+ // 2. 按周拆分输入。
+ weekOrder, weekEntries := splitHybridEntriesByWeek(st.HybridEntries)
+ if len(weekOrder) == 0 {
+ st.ReactDone = true
+ st.ReactSummary = "无可优化的排程条目。"
+ return st, nil
+ }
+
+ // 3. 只对“包含 suggested 的周”分配预算,其余周直接透传。
+ activeWeeks := make([]int, 0, len(weekOrder))
+ for _, week := range weekOrder {
+ if countSuggested(weekEntries[week]) > 0 {
+ activeWeeks = append(activeWeeks, week)
+ }
+ }
+ if len(activeWeeks) == 0 {
+ st.ReactDone = true
+ st.ReactSummary = "当前方案中没有可调整的 suggested 任务,已跳过周级优化。"
+ return st, nil
+ }
+
+ // 3.1 强制“每个有效周至少 1 个总预算 + 1 个有效预算”。
+ // 3.1.1 判断依据:任何有效周都必须有机会进入优化,避免出现 0 预算跳过。
+ // 3.1.2 实现方式:当全局预算不足时,自动抬升到 activeWeeks 数量。
+ // 3.1.3 失败/兜底:该步骤仅做内存字段修正,不依赖外部资源,不会新增失败点。
+ minBudgetRequired := len(activeWeeks)
+ if st.WeeklyTotalBudget < minBudgetRequired {
+ st.WeeklyTotalBudget = minBudgetRequired
+ }
+ if st.WeeklyAdjustBudget < minBudgetRequired {
+ st.WeeklyAdjustBudget = minBudgetRequired
+ }
+ if st.WeeklyAdjustBudget > st.WeeklyTotalBudget {
+ st.WeeklyAdjustBudget = st.WeeklyTotalBudget
+ }
+
+ totalBudgetByWeek, effectiveBudgetByWeek, weeklyLoads, coveredWeeks := splitWeeklyBudgetsByLoad(
+ activeWeeks,
+ weekEntries,
+ st.WeeklyTotalBudget,
+ st.WeeklyAdjustBudget,
+ )
+ budgetIndexByWeek := make(map[int]int, len(activeWeeks))
+ for idx, week := range activeWeeks {
+ budgetIndexByWeek[week] = idx
+ }
+ if coveredWeeks < len(activeWeeks) {
+ emitStage(
+ "schedule_plan.weekly_refine.budget_fallback",
+ fmt.Sprintf(
+ "周级预算不足以覆盖全部有效周(有效周=%d,至少需预算=%d;当前总预算=%d,有效预算=%d)。已按周负载优先覆盖 %d 个周,其余周预算置 0 并透传原方案。",
+ len(activeWeeks),
+ len(activeWeeks),
+ st.WeeklyTotalBudget,
+ st.WeeklyAdjustBudget,
+ coveredWeeks,
+ ),
+ )
+ }
+
+ workerConcurrency := st.WeeklyRefineConcurrency
+ if workerConcurrency > len(activeWeeks) {
+ workerConcurrency = len(activeWeeks)
+ }
+ if workerConcurrency <= 0 {
+ workerConcurrency = 1
+ }
+
+ emitStage(
+ "schedule_plan.weekly_refine.start",
+ fmt.Sprintf(
+ "周级单步优化开始:周数=%d(可优化=%d),并发度=%d,总动作预算=%d,有效动作预算=%d,覆盖周=%d/%d,周负载=%v。",
+ len(weekOrder),
+ len(activeWeeks),
+ workerConcurrency,
+ st.WeeklyTotalBudget,
+ st.WeeklyAdjustBudget,
+ coveredWeeks,
+ len(activeWeeks),
+ weeklyLoads,
+ ),
+ )
+
+ // 4. 并发执行“单周 worker”。
+ sem := make(chan struct{}, workerConcurrency)
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+
+ workerResults := make(map[int]weeklyRefineWorkerResult, len(weekOrder))
+ var firstErr error
+ completedWeeks := 0
+
+ for _, week := range weekOrder {
+ week := week
+ entries := deepCopyEntries(weekEntries[week])
+
+ // 4.1 没有 suggested 的周直接透传,不占模型调用预算。
+ if countSuggested(entries) == 0 {
+ workerResults[week] = weeklyRefineWorkerResult{
+ Week: week,
+ Entries: entries,
+ Summary: fmt.Sprintf("W%d 无 suggested 任务,跳过周级优化。", week),
+ }
+ continue
+ }
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+
+ select {
+ case sem <- struct{}{}:
+ defer func() { <-sem }()
+ case <-ctx.Done():
+ mu.Lock()
+ if firstErr == nil {
+ firstErr = ctx.Err()
+ }
+ completedWeeks++
+ workerResults[week] = weeklyRefineWorkerResult{
+ Week: week,
+ Entries: entries,
+ Summary: fmt.Sprintf("W%d 优化取消,已保留原方案。", week),
+ }
+ emitStage(
+ "schedule_plan.weekly_refine.week_done",
+ fmt.Sprintf("W%d 已取消并回退原方案。(进度 %d/%d)", week, completedWeeks, len(activeWeeks)),
+ )
+ mu.Unlock()
+ return
+ }
+
+ idx := budgetIndexByWeek[week]
+ weekTotalBudget := totalBudgetByWeek[idx]
+ weekEffectiveBudget := effectiveBudgetByWeek[idx]
+ emitStage(
+ "schedule_plan.weekly_refine.week_start",
+ fmt.Sprintf(
+ "W%d 开始周级单步优化:总预算=%d,有效预算=%d。",
+ week,
+ weekTotalBudget,
+ weekEffectiveBudget,
+ ),
+ )
+
+ result, workerErr := runSingleWeekRefineWorker(
+ ctx,
+ chatModel,
+ modelName,
+ week,
+ entries,
+ st.Constraints,
+ weeklyPlanningWindow{
+ Enabled: st.HasPlanningWindow,
+ StartWeek: st.PlanStartWeek,
+ StartDay: st.PlanStartDay,
+ EndWeek: st.PlanEndWeek,
+ EndDay: st.PlanEndDay,
+ },
+ weekTotalBudget,
+ weekEffectiveBudget,
+ emitStage,
+ )
+
+ mu.Lock()
+ defer mu.Unlock()
+ if workerErr != nil && firstErr == nil {
+ firstErr = workerErr
+ }
+ completedWeeks++
+ workerResults[week] = result
+ emitStage(
+ "schedule_plan.weekly_refine.week_done",
+ fmt.Sprintf(
+ "W%d 周级优化完成(总已用=%d/%d,有效已用=%d/%d)。(进度 %d/%d)",
+ week,
+ result.TotalUsed,
+ weekTotalBudget,
+ result.EffectiveUsed,
+ weekEffectiveBudget,
+ completedWeeks,
+ len(activeWeeks),
+ ),
+ )
+ }()
+ }
+ wg.Wait()
+
+ // 5. 汇总 worker 结果,重建全量 HybridEntries。
+ mergedEntries := make([]model.HybridScheduleEntry, 0, len(st.HybridEntries))
+ st.WeeklyTotalUsed = 0
+ st.WeeklyAdjustUsed = 0
+ st.WeeklyActionLogs = st.WeeklyActionLogs[:0]
+ weekSummaries := make([]string, 0, len(weekOrder))
+
+ for _, week := range weekOrder {
+ result, exists := workerResults[week]
+ if !exists {
+ // 理论上不会发生;兜底透传该周原始条目。
+ result = weeklyRefineWorkerResult{
+ Week: week,
+ Entries: deepCopyEntries(weekEntries[week]),
+ Summary: fmt.Sprintf("W%d 未拿到 worker 结果,已保留原方案。", week),
+ }
+ }
+ mergedEntries = append(mergedEntries, result.Entries...)
+ st.WeeklyTotalUsed += result.TotalUsed
+ st.WeeklyAdjustUsed += result.EffectiveUsed
+ st.WeeklyActionLogs = append(st.WeeklyActionLogs, result.ActionLogs...)
+ if strings.TrimSpace(result.Summary) != "" {
+ weekSummaries = append(weekSummaries, result.Summary)
+ }
+ }
+ sortHybridEntries(mergedEntries)
+ st.HybridEntries = mergedEntries
+
+ // 6. 生成阶段摘要并收口状态。
+ st.ReactDone = true
+ st.ReactRound = st.WeeklyTotalUsed
+ if len(weekSummaries) == 0 {
+ st.ReactSummary = fmt.Sprintf(
+ "周级优化完成:总动作已用 %d/%d,有效动作已用 %d/%d。",
+ st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget,
+ )
+ } else {
+ st.ReactSummary = strings.Join(weekSummaries, ";")
+ }
+ if firstErr != nil {
+ emitStage("schedule_plan.weekly_refine.partial_error", fmt.Sprintf("周级并发优化部分失败,已自动保留失败周原方案。原因:%s", firstErr.Error()))
+ }
+ emitStage(
+ "schedule_plan.weekly_refine.done",
+ fmt.Sprintf(
+ "周级单步优化结束:总动作已用 %d/%d,有效动作已用 %d/%d。",
+ st.WeeklyTotalUsed, st.WeeklyTotalBudget, st.WeeklyAdjustUsed, st.WeeklyAdjustBudget,
+ ),
+ )
+ return st, nil
+}
+
+// runSingleWeekRefineWorker 执行“单周 + 单步动作”循环。
+//
+// 流程说明:
+// 1. 每轮只允许 1 个工具调用或 done;
+// 2. 每次工具调用都扣“总预算”;
+// 3. 仅成功调用再扣“有效预算”;
+// 4. 工具结果会回灌到下一轮上下文,驱动“走一步看一步”。
+func runSingleWeekRefineWorker(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ modelName string,
+ week int,
+ entries []model.HybridScheduleEntry,
+ constraints []string,
+ window weeklyPlanningWindow,
+ totalBudget int,
+ effectiveBudget int,
+ emitStage func(stage, detail string),
+) (weeklyRefineWorkerResult, error) {
+ result := weeklyRefineWorkerResult{
+ Week: week,
+ Entries: deepCopyEntries(entries),
+ }
+
+ if totalBudget <= 0 || effectiveBudget <= 0 {
+ result.Summary = fmt.Sprintf("W%d 预算为 0,跳过周级优化。", week)
+ return result, nil
+ }
+
+ hybridJSON, err := json.Marshal(result.Entries)
+ if err != nil {
+ result.Summary = fmt.Sprintf("W%d 序列化失败,已保留原方案。", week)
+ return result, fmt.Errorf("周级 worker 序列化失败 week=%d: %w", week, err)
+ }
+ constraintsText := "无"
+ if len(constraints) > 0 {
+ constraintsText = strings.Join(constraints, "、")
+ }
+
+ messages := []*schema.Message{
+ schema.SystemMessage(
+ renderWeeklyPromptWithBudget(
+ effectiveBudget-result.EffectiveUsed,
+ effectiveBudget,
+ result.EffectiveUsed,
+ totalBudget-result.TotalUsed,
+ totalBudget,
+ result.TotalUsed,
+ ),
+ ),
+ schema.UserMessage(fmt.Sprintf(
+ "当前处理周次:W%d\n以下是当前周混合日程(JSON):\n%s\n\n用户约束:%s\n\n注意:本 worker 仅允许优化 W%d 内的任务。",
+ week,
+ string(hybridJSON),
+ constraintsText,
+ week,
+ )),
+ }
+
+ for result.TotalUsed < totalBudget && result.EffectiveUsed < effectiveBudget {
+ remainingTotal := totalBudget - result.TotalUsed
+ remainingEffective := effectiveBudget - result.EffectiveUsed
+ emitStage(
+ "schedule_plan.weekly_refine.round",
+ fmt.Sprintf(
+ "W%d 新一轮决策:总预算剩余=%d/%d,有效预算剩余=%d/%d。",
+ week,
+ remainingTotal,
+ totalBudget,
+ remainingEffective,
+ effectiveBudget,
+ ),
+ )
+
+ // 1. 每轮更新系统提示中的预算占位符。
+ messages[0] = schema.SystemMessage(
+ renderWeeklyPromptWithBudget(
+ remainingEffective,
+ effectiveBudget,
+ result.EffectiveUsed,
+ remainingTotal,
+ totalBudget,
+ result.TotalUsed,
+ ),
+ )
+
+ roundCtx, cancel := context.WithTimeout(ctx, weeklyReactRoundTimeout)
+ content, genErr := generateWeeklyRefineRound(roundCtx, chatModel, messages)
+ cancel()
+ if genErr != nil {
+ result.Summary = fmt.Sprintf("W%d 模型调用失败,已保留当前结果。", week)
+ return result, fmt.Errorf("周级 worker 调用失败 week=%d: %w", week, genErr)
+ }
+
+ parsed, parseErr := parseReactLLMOutput(content)
+ if parseErr != nil {
+ result.Summary = fmt.Sprintf("W%d 输出格式异常,已保留当前结果。", week)
+ return result, fmt.Errorf("周级 worker 解析失败 week=%d: %w", week, parseErr)
+ }
+
+ // 2. done=true 直接正常结束,不再消耗预算。
+ if parsed.Done {
+ summary := strings.TrimSpace(parsed.Summary)
+ if summary == "" {
+ summary = fmt.Sprintf(
+ "W%d 优化结束(总动作已用 %d/%d,有效动作已用 %d/%d)。",
+ week,
+ result.TotalUsed, totalBudget,
+ result.EffectiveUsed, effectiveBudget,
+ )
+ }
+ result.Summary = summary
+ break
+ }
+
+ // 3. 只取一个工具调用,强制单步。
+ call, warn := pickSingleToolCall(parsed.ToolCalls)
+ if call == nil {
+ result.Summary = fmt.Sprintf(
+ "W%d 无可执行动作,提前结束(总动作已用 %d/%d,有效动作已用 %d/%d)。",
+ week,
+ result.TotalUsed, totalBudget,
+ result.EffectiveUsed, effectiveBudget,
+ )
+ break
+ }
+ if warn != "" {
+ result.ActionLogs = append(result.ActionLogs, fmt.Sprintf("W%d 警告:%s", week, warn))
+ }
+
+ // 4. 执行工具:总预算总是扣减;有效预算仅成功时扣减。
+ result.TotalUsed++
+ nextEntries, toolResult := dispatchWeeklySingleActionTool(result.Entries, *call, week, window)
+ if toolResult.Success {
+ result.EffectiveUsed++
+ result.Entries = nextEntries
+ }
+
+ logLine := fmt.Sprintf(
+ "W%d 动作[%s] 结果=%t,总预算=%d/%d,有效预算=%d/%d,详情=%s",
+ week,
+ toolResult.Tool,
+ toolResult.Success,
+ result.TotalUsed,
+ totalBudget,
+ result.EffectiveUsed,
+ effectiveBudget,
+ toolResult.Result,
+ )
+ result.ActionLogs = append(result.ActionLogs, logLine)
+ statusMark := "FAIL"
+ if toolResult.Success {
+ statusMark = "OK"
+ }
+ emitStage("schedule_plan.weekly_refine.tool_call", fmt.Sprintf("[%s] %s", statusMark, logLine))
+
+ // 5. 把“本轮输出 + 工具结果”拼回下一轮上下文,驱动增量推理。
+ messages = append(messages, schema.AssistantMessage(content, nil))
+ toolResultJSON, _ := json.Marshal([]reactToolResult{toolResult})
+ messages = append(messages, schema.UserMessage(
+ fmt.Sprintf(
+ "上一轮工具结果:%s\n当前预算:总剩余=%d,有效剩余=%d\n请继续按“单步动作”规则决策(仅一个工具调用或 done)。",
+ string(toolResultJSON),
+ totalBudget-result.TotalUsed,
+ effectiveBudget-result.EffectiveUsed,
+ ),
+ ))
+ }
+
+ if strings.TrimSpace(result.Summary) == "" {
+ result.Summary = fmt.Sprintf(
+ "W%d 预算耗尽停止(总动作已用 %d/%d,有效动作已用 %d/%d)。",
+ week,
+ result.TotalUsed, totalBudget,
+ result.EffectiveUsed, effectiveBudget,
+ )
+ }
+ return result, nil
+}
+
+// generateWeeklyRefineRound 调用模型生成“单周单步”决策输出。
+//
+// 说明:
+// 1. 周级仍保留 thinking(提高复杂排程准确率);
+// 2. 但不把 reasoning 实时透传给前端,避免刷屏;
+// 3. 仅返回最终 content,交给 JSON 解析器处理。
+func generateWeeklyRefineRound(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ messages []*schema.Message,
+) (string, error) {
+ return agentllm.GenerateScheduleWeeklyReactRound(ctx, chatModel, messages)
+}
+
+// renderWeeklyPromptWithBudget 渲染周级单步优化的预算占位符。
+//
+// 1. 保留旧占位符 {{budget*}} 兼容历史模板;
+// 2. 新增 action_total/action_effective 占位符表达双预算语义;
+// 3. 所有负值都会在这里兜底归零,避免传给模型异常预算。
+func renderWeeklyPromptWithBudget(
+ remainingEffective int,
+ effectiveBudget int,
+ usedEffective int,
+ remainingTotal int,
+ totalBudget int,
+ usedTotal int,
+) string {
+ if effectiveBudget <= 0 {
+ effectiveBudget = schedulePlanDefaultWeeklyAdjustBudget
+ }
+ if totalBudget <= 0 {
+ totalBudget = schedulePlanDefaultWeeklyTotalBudget
+ }
+ if remainingEffective < 0 {
+ remainingEffective = 0
+ }
+ if remainingTotal < 0 {
+ remainingTotal = 0
+ }
+ if usedEffective < 0 {
+ usedEffective = 0
+ }
+ if usedTotal < 0 {
+ usedTotal = 0
+ }
+ if usedEffective > effectiveBudget {
+ usedEffective = effectiveBudget
+ }
+ if usedTotal > totalBudget {
+ usedTotal = totalBudget
+ }
+
+ prompt := agentprompt.SchedulePlanWeeklyReactPrompt
+ prompt = strings.ReplaceAll(prompt, "{{action_total_remaining}}", fmt.Sprintf("%d", remainingTotal))
+ prompt = strings.ReplaceAll(prompt, "{{action_total_budget}}", fmt.Sprintf("%d", totalBudget))
+ prompt = strings.ReplaceAll(prompt, "{{action_total_used}}", fmt.Sprintf("%d", usedTotal))
+ prompt = strings.ReplaceAll(prompt, "{{action_effective_remaining}}", fmt.Sprintf("%d", remainingEffective))
+ prompt = strings.ReplaceAll(prompt, "{{action_effective_budget}}", fmt.Sprintf("%d", effectiveBudget))
+ prompt = strings.ReplaceAll(prompt, "{{action_effective_used}}", fmt.Sprintf("%d", usedEffective))
+
+ // 兼容旧模板占位符,避免历史 prompt 残留时出现未替换文本。
+ prompt = strings.ReplaceAll(prompt, "{{budget_remaining}}", fmt.Sprintf("%d", remainingEffective))
+ prompt = strings.ReplaceAll(prompt, "{{budget_total}}", fmt.Sprintf("%d", effectiveBudget))
+ prompt = strings.ReplaceAll(prompt, "{{budget_used}}", fmt.Sprintf("%d", usedEffective))
+ prompt = strings.ReplaceAll(prompt, "{{budget}}", fmt.Sprintf("%d(总额度 %d,已用 %d)", remainingEffective, effectiveBudget, usedEffective))
+ return prompt
+}
+
+// pickSingleToolCall 在“单步动作模式”下选择一个工具调用。
+//
+// 返回语义:
+// 1. call=nil:没有可执行工具;
+// 2. warn 非空:模型返回了多个工具,本轮仅执行第一个。
+func pickSingleToolCall(toolCalls []reactToolCall) (*reactToolCall, string) {
+ if len(toolCalls) == 0 {
+ return nil, ""
+ }
+ call := toolCalls[0]
+ if len(toolCalls) == 1 {
+ return &call, ""
+ }
+ return &call, fmt.Sprintf("模型返回了 %d 个工具调用,单步模式仅执行第一个:%s", len(toolCalls), call.Tool)
+}
+
+// splitHybridEntriesByWeek 按 week 对混合条目分组并返回稳定周序。
+func splitHybridEntriesByWeek(entries []model.HybridScheduleEntry) ([]int, map[int][]model.HybridScheduleEntry) {
+ byWeek := make(map[int][]model.HybridScheduleEntry)
+ for _, entry := range entries {
+ byWeek[entry.Week] = append(byWeek[entry.Week], entry)
+ }
+ weeks := make([]int, 0, len(byWeek))
+ for week := range byWeek {
+ weeks = append(weeks, week)
+ }
+ sort.Ints(weeks)
+ return weeks, byWeek
+}
+
+type weightedBudgetRemainder struct {
+ Index int
+ Remainder int
+ Load int
+}
+
+// splitWeeklyBudgetsByLoad 根据“有效周保底 + 周负载加权”拆分预算。
+//
+// 职责边界:
+// 1. 负责:返回与 activeWeeks 同索引对齐的总预算/有效预算;
+// 2. 负责:在预算不足时按负载优先覆盖高负载周;
+// 3. 不负责:执行周级动作与状态落盘(由 runSingleWeekRefineWorker / runWeeklyRefineNode 负责)。
+//
+// 输入输出语义:
+// 1. coveredWeeks 表示“同时拿到 >=1 总预算和 >=1 有效预算”的周数;
+// 2. 当任一全局预算 <=0 时,返回全 0;上游将据此跳过对应周优化;
+// 3. 返回的 weeklyLoads 仅用于可观测性,不参与后续状态持久化。
+func splitWeeklyBudgetsByLoad(
+ activeWeeks []int,
+ weekEntries map[int][]model.HybridScheduleEntry,
+ totalBudget int,
+ effectiveBudget int,
+) (totalByWeek []int, effectiveByWeek []int, weeklyLoads []int, coveredWeeks int) {
+ weekCount := len(activeWeeks)
+ if weekCount == 0 {
+ return nil, nil, nil, 0
+ }
+
+ if totalBudget < 0 {
+ totalBudget = 0
+ }
+ if effectiveBudget < 0 {
+ effectiveBudget = 0
+ }
+
+ weeklyLoads = buildWeeklyLoadScores(activeWeeks, weekEntries)
+ totalByWeek = make([]int, weekCount)
+ effectiveByWeek = make([]int, weekCount)
+ if totalBudget == 0 || effectiveBudget == 0 {
+ return totalByWeek, effectiveByWeek, weeklyLoads, 0
+ }
+
+ // 1. 先计算“可保底覆盖周数”。
+ // 1.1 目标是每个有效周至少 1 个总预算 + 1 个有效预算;
+ // 1.2 失败场景:当预算小于有效周数量时,不可能全覆盖;
+ // 1.3 兜底策略:只覆盖高负载周,避免把预算分散到无法执行的周。
+ coveredWeeks = weekCount
+ if totalBudget < coveredWeeks {
+ coveredWeeks = totalBudget
+ }
+ if effectiveBudget < coveredWeeks {
+ coveredWeeks = effectiveBudget
+ }
+ if coveredWeeks <= 0 {
+ return totalByWeek, effectiveByWeek, weeklyLoads, 0
+ }
+
+ coveredIndexes := pickTopLoadWeekIndexes(weeklyLoads, coveredWeeks)
+ for _, idx := range coveredIndexes {
+ totalByWeek[idx]++
+ effectiveByWeek[idx]++
+ }
+
+ // 2. 再把剩余预算按周负载加权分配。
+ // 2.1 判断依据:负载越高,给到的额外预算越多,优先解决高密度周;
+ // 2.2 失败场景:负载异常(<=0)会导致权重失真;
+ // 2.3 兜底策略:权重最小按 1 处理,保证分配可持续、不会 panic。
+ addWeightedBudget(totalByWeek, weeklyLoads, coveredIndexes, totalBudget-coveredWeeks)
+ addWeightedBudget(effectiveByWeek, weeklyLoads, coveredIndexes, effectiveBudget-coveredWeeks)
+ return totalByWeek, effectiveByWeek, weeklyLoads, coveredWeeks
+}
+
+// buildWeeklyLoadScores 计算每个有效周的负载评分。
+//
+// 职责边界:
+// 1. 负责:以 suggested 任务的节次跨度作为周负载;
+// 2. 不负责:预算分配策略与排序决策(由 splitWeeklyBudgetsByLoad/pickTopLoadWeekIndexes 负责)。
+func buildWeeklyLoadScores(
+ activeWeeks []int,
+ weekEntries map[int][]model.HybridScheduleEntry,
+) []int {
+ loads := make([]int, len(activeWeeks))
+ for idx, week := range activeWeeks {
+ load := 0
+ for _, entry := range weekEntries[week] {
+ if entry.Status != "suggested" {
+ continue
+ }
+ span := entry.SectionTo - entry.SectionFrom + 1
+ if span <= 0 {
+ span = 1
+ }
+ load += span
+ }
+ if load <= 0 {
+ // 兜底:脏数据或异常节次下仍给该周最小权重,避免被完全饿死。
+ load = 1
+ }
+ loads[idx] = load
+ }
+ return loads
+}
+
+// pickTopLoadWeekIndexes 选择负载最高的 topN 个周索引。
+func pickTopLoadWeekIndexes(loads []int, topN int) []int {
+ if topN <= 0 || len(loads) == 0 {
+ return nil
+ }
+ indexes := make([]int, len(loads))
+ for i := range loads {
+ indexes[i] = i
+ }
+ sort.SliceStable(indexes, func(i, j int) bool {
+ left := loads[indexes[i]]
+ right := loads[indexes[j]]
+ if left != right {
+ return left > right
+ }
+ return indexes[i] < indexes[j]
+ })
+ if topN > len(indexes) {
+ topN = len(indexes)
+ }
+ selected := append([]int(nil), indexes[:topN]...)
+ sort.Ints(selected)
+ return selected
+}
+
+// addWeightedBudget 把剩余预算按权重分配到目标周。
+//
+// 说明:
+// 1. 先按整数份额分配;
+// 2. 再按“最大余数法”分发尾差,保证总和严格守恒;
+// 3. 余数相同时优先高负载周,再按索引稳定排序,避免结果抖动。
+func addWeightedBudget(
+ budgets []int,
+ loads []int,
+ targetIndexes []int,
+ remainingBudget int,
+) {
+ if remainingBudget <= 0 || len(targetIndexes) == 0 {
+ return
+ }
+
+ totalLoad := 0
+ normalizedLoadByIndex := make(map[int]int, len(targetIndexes))
+ for _, idx := range targetIndexes {
+ load := 1
+ if idx >= 0 && idx < len(loads) && loads[idx] > 0 {
+ load = loads[idx]
+ }
+ normalizedLoadByIndex[idx] = load
+ totalLoad += load
+ }
+ if totalLoad <= 0 {
+ // 理论上不会出现;兜底均匀轮询分配,保证不会丢预算。
+ for i := 0; i < remainingBudget; i++ {
+ budgets[targetIndexes[i%len(targetIndexes)]]++
+ }
+ return
+ }
+
+ allocated := 0
+ remainders := make([]weightedBudgetRemainder, 0, len(targetIndexes))
+ for _, idx := range targetIndexes {
+ load := normalizedLoadByIndex[idx]
+ shareProduct := remainingBudget * load
+ share := shareProduct / totalLoad
+ budgets[idx] += share
+ allocated += share
+ remainders = append(remainders, weightedBudgetRemainder{
+ Index: idx,
+ Remainder: shareProduct % totalLoad,
+ Load: load,
+ })
+ }
+
+ left := remainingBudget - allocated
+ if left <= 0 {
+ return
+ }
+ sort.SliceStable(remainders, func(i, j int) bool {
+ if remainders[i].Remainder != remainders[j].Remainder {
+ return remainders[i].Remainder > remainders[j].Remainder
+ }
+ if remainders[i].Load != remainders[j].Load {
+ return remainders[i].Load > remainders[j].Load
+ }
+ return remainders[i].Index < remainders[j].Index
+ })
+ for i := 0; i < left; i++ {
+ budgets[remainders[i%len(remainders)].Index]++
+ }
+}
+
+// sortHybridEntries 对条目做稳定排序,确保后续预览输出稳定。
+func sortHybridEntries(entries []model.HybridScheduleEntry) {
+ sort.SliceStable(entries, func(i, j int) bool {
+ left := entries[i]
+ right := entries[j]
+ if left.Week != right.Week {
+ return left.Week < right.Week
+ }
+ if left.DayOfWeek != right.DayOfWeek {
+ return left.DayOfWeek < right.DayOfWeek
+ }
+ if left.SectionFrom != right.SectionFrom {
+ return left.SectionFrom < right.SectionFrom
+ }
+ if left.SectionTo != right.SectionTo {
+ return left.SectionTo < right.SectionTo
+ }
+ if left.Status != right.Status {
+ // existing 放前,suggested 放后,便于观察课表底板与建议层。
+ return left.Status < right.Status
+ }
+ return left.Name < right.Name
+ })
+}
+
+// runFinalCheckNode 负责“终审校验 + 总结生成”。
+//
+// 职责边界:
+// 1. 负责执行物理校验(冲突、节次越界、数量核对);
+// 2. 负责在校验失败时回退到 MergeSnapshot;
+// 3. 负责生成最终给用户看的自然语言总结;
+// 4. 不负责写库(本期只做预览)。
+func runFinalCheckNode(
+ ctx context.Context,
+ st *SchedulePlanState,
+ chatModel *ark.ChatModel,
+ emitStage func(stage, detail string),
+) (*SchedulePlanState, error) {
+ if st == nil {
+ return nil, fmt.Errorf("schedule plan final check: nil state")
+ }
+
+ emitStage("schedule_plan.final_check.start", "正在进行终审校验。")
+
+ // 1. 先做物理校验。
+ issues := physicsCheck(st)
+ if len(issues) > 0 {
+ emitStage("schedule_plan.final_check.issues", fmt.Sprintf("发现 %d 个问题,已回退到日内优化结果。", len(issues)))
+ // 1.1 回退策略:
+ // 1.1.1 优先回退到 merge 快照(已经过冲突校验);
+ // 1.1.2 若快照为空,保留当前结果继续走总结,保证可返回。
+ if len(st.MergeSnapshot) > 0 {
+ st.HybridEntries = deepCopyEntries(st.MergeSnapshot)
+ }
+ }
+
+ // 2. 生成人性化总结。
+ //
+ // 2.1 总结失败不影响主流程;
+ // 2.2 失败时使用兜底文案,保证前端始终有可展示文本。
+ summary, err := generateHumanSummary(ctx, chatModel, st.HybridEntries, st.Constraints, st.WeeklyActionLogs)
+ if err != nil || strings.TrimSpace(summary) == "" {
+ st.FinalSummary = fmt.Sprintf("排程优化完成,共安排了 %d 个任务。", countSuggested(st.HybridEntries))
+ } else {
+ st.FinalSummary = strings.TrimSpace(summary)
+ }
+
+ emitStage("schedule_plan.final_check.done", "终审校验完成。")
+ return st, nil
+}
+
+// physicsCheck 执行物理层面校验。
+//
+// 校验项:
+// 1. 时间冲突:同一 slot 不允许多任务占用;
+// 2. 节次越界:section 必须落在 1..12 且 from<=to;
+// 3. 数量核对:suggested 数量应与原始 AllocatedItems 数量一致。
+func physicsCheck(st *SchedulePlanState) []string {
+ issues := make([]string, 0)
+ if st == nil {
+ return append(issues, "state 为空")
+ }
+
+ // 1. 时间冲突校验。
+ if conflict := detectConflicts(st.HybridEntries); conflict != "" {
+ issues = append(issues, "时间冲突:"+conflict)
+ }
+
+ // 2. 节次越界校验。
+ for _, entry := range st.HybridEntries {
+ if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo {
+ issues = append(
+ issues,
+ fmt.Sprintf("节次越界:[%s] W%dD%d 第%d-%d节", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo),
+ )
+ }
+ }
+
+ // 3. 数量一致性校验。
+ // 3.1 判断依据:suggested 表示“待应用任务块”,应与 allocatedItems 数量匹配;
+ // 3.2 若不匹配,可能表示工具调用丢失或重复覆盖。
+ suggestedCount := countSuggested(st.HybridEntries)
+ if suggestedCount != len(st.AllocatedItems) {
+ issues = append(
+ issues,
+ fmt.Sprintf("任务数量不匹配:suggested=%d,原始分配=%d", suggestedCount, len(st.AllocatedItems)),
+ )
+ }
+
+ return issues
+}
+
+// countSuggested 统计 suggested 条目数量。
+func countSuggested(entries []model.HybridScheduleEntry) int {
+ count := 0
+ for _, entry := range entries {
+ if entry.Status == "suggested" {
+ count++
+ }
+ }
+ return count
+}
+
+// generateHumanSummary 调用模型生成“用户可读”的总结文案。
+//
+// 职责边界:
+// 1. 只做读模型,不修改任何 state;
+// 2. 输出纯文本;
+// 3. 失败时把错误返回给上层,由上层决定兜底文案。
+func generateHumanSummary(
+ ctx context.Context,
+ chatModel *ark.ChatModel,
+ entries []model.HybridScheduleEntry,
+ constraints []string,
+ actionLogs []string,
+) (string, error) {
+ return agentllm.GenerateScheduleHumanSummary(ctx, chatModel, entries, constraints, actionLogs)
}
diff --git a/backend/agent2/node/schedule_plan_tool.go b/backend/agent2/node/schedule_plan_tool.go
new file mode 100644
index 0000000..ab35a57
--- /dev/null
+++ b/backend/agent2/node/schedule_plan_tool.go
@@ -0,0 +1,571 @@
+package agentnode
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
+ "github.com/LoveLosita/smartflow/backend/model"
+)
+
+// SchedulePlanToolDeps 描述“智能排程 graph”运行所需的外部业务依赖。
+//
+// 职责边界:
+// 1. 只负责声明“需要哪些能力”,不负责具体实现(实现由 service 层注入)。
+// 2. 只收口函数签名,不承载业务状态,避免跨请求共享可变数据。
+// 3. 当前统一采用 task_class_ids 语义,不再依赖单 task_class_id 主路径。
+type SchedulePlanToolDeps struct {
+ // SmartPlanningMultiRaw 是可选依赖:
+ // 1) 用于需要单独输出“粗排预览”时复用;
+ // 2) 当前主链路已由 HybridScheduleWithPlanMulti 覆盖,可不注入。
+ SmartPlanningMultiRaw func(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error)
+
+ // HybridScheduleWithPlanMulti 把“既有日程 + 粗排结果”合并成统一的 HybridScheduleEntry 切片,
+ // 供 daily/weekly ReAct 节点在内存中继续优化。
+ HybridScheduleWithPlanMulti func(ctx context.Context, userID int, taskClassIDs []int) ([]model.HybridScheduleEntry, []model.TaskClassItem, error)
+
+ // ResolvePlanningWindow 根据 task_class_ids 解析“全局排程窗口”的相对周/天边界。
+ //
+ // 返回语义:
+ // 1. startWeek/startDay:窗口起点(含);
+ // 2. endWeek/endDay:窗口终点(含);
+ // 3. error:解析失败(如任务类不存在、日期非法)。
+ //
+ // 用途:
+ // 1. 给周级 Move 工具加硬边界,避免把任务移动到窗口外的天数;
+ // 2. 解决“首尾不足一周”场景下的周内越界问题。
+ ResolvePlanningWindow func(ctx context.Context, userID int, taskClassIDs []int) (startWeek, startDay, endWeek, endDay int, err error)
+}
+
+// Validate 校验依赖完整性。
+//
+// 失败处理:
+// 1. 任意依赖缺失都直接返回错误,避免 graph 运行到中途才 panic。
+// 2. 调用方(runSchedulePlanFlow)收到错误后会走回退链路,不影响普通聊天可用性。
+func (d SchedulePlanToolDeps) Validate() error {
+ if d.HybridScheduleWithPlanMulti == nil {
+ return errors.New("schedule plan tool deps: HybridScheduleWithPlanMulti 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
+ }
+}
+
+// ExtraIntSlice 从 extra map 中安全提取整数切片。
+//
+// 兼容输入:
+// 1) []any(JSON 数组反序列化后的常见类型);
+// 2) []int;
+// 3) []float64;
+// 4) 逗号分隔字符串(例如 "1,2,3")。
+//
+// 返回语义:
+// 1) ok=true:至少成功解析出一个整数;
+// 2) ok=false:字段不存在或全部解析失败。
+func ExtraIntSlice(extra map[string]any, key string) ([]int, bool) {
+ v, exists := extra[key]
+ if !exists {
+ return nil, false
+ }
+
+ parseOne := func(raw any) (int, error) {
+ switch n := raw.(type) {
+ case int:
+ return n, nil
+ case float64:
+ return int(n), nil
+ case string:
+ i, err := strconv.Atoi(n)
+ if err != nil {
+ return 0, err
+ }
+ return i, nil
+ default:
+ return 0, fmt.Errorf("unsupported type: %T", raw)
+ }
+ }
+
+ out := make([]int, 0)
+ switch arr := v.(type) {
+ case []int:
+ for _, item := range arr {
+ out = append(out, item)
+ }
+ case []float64:
+ for _, item := range arr {
+ out = append(out, int(item))
+ }
+ case []any:
+ for _, item := range arr {
+ if parsed, err := parseOne(item); err == nil {
+ out = append(out, parsed)
+ }
+ }
+ case string:
+ parts := strings.Split(arr, ",")
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if parsed, err := strconv.Atoi(part); err == nil {
+ out = append(out, parsed)
+ }
+ }
+ default:
+ return nil, false
+ }
+
+ if len(out) == 0 {
+ return nil, false
+ }
+ return out, true
+}
+
+// ── ReAct Tool 调用/结果结构 ──
+
+// reactToolCall 是 LLM 输出的单个工具调用。
+type reactToolCall = agentllm.ReactToolCall
+
+// reactToolResult 是单个工具调用的执行结果。
+type reactToolResult struct {
+ Tool string `json:"tool"`
+ Success bool `json:"success"`
+ Result string `json:"result"`
+}
+
+// reactLLMOutput 是 LLM 输出的完整 JSON 结构。
+type reactLLMOutput = agentllm.ReactLLMOutput
+
+// weeklyPlanningWindow 表示周级优化可用的全局周/天窗口。
+//
+// 语义:
+// 1. Enabled=false:不启用窗口硬边界,仅做基础合法性校验;
+// 2. Enabled=true:Move 必须落在 [StartWeek/StartDay, EndWeek/EndDay] 内;
+// 3. 该窗口用于处理“首尾不足一周”场景下的越界移动问题。
+type weeklyPlanningWindow struct {
+ Enabled bool
+ StartWeek int
+ StartDay int
+ EndWeek int
+ EndDay int
+}
+
+// ── 工具分发器 ──
+
+// 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)}
+ }
+}
+
+// dispatchWeeklySingleActionTool 是“周级单步动作模式”的专用分发器。
+//
+// 职责边界:
+// 1. 仅允许 Move / Swap 两个工具,禁止 TimeAvailable / GetAvailableSlots;
+// 2. 强制 Move 的目标周必须等于 currentWeek,避免并发周优化时发生跨周写穿;
+// 3. 统一返回工具执行结果,供上层决定预算扣减与下一轮上下文拼接。
+func dispatchWeeklySingleActionTool(entries []model.HybridScheduleEntry, call reactToolCall, currentWeek int, window weeklyPlanningWindow) ([]model.HybridScheduleEntry, reactToolResult) {
+ tool := strings.TrimSpace(call.Tool)
+ switch tool {
+ case "Swap":
+ return reactToolSwap(entries, call.Params)
+ case "Move":
+ // 1. 周级并发模式下,每个 worker 只负责单周数据。
+ // 2. 为避免“一个 worker 改到别的周”导致并发写冲突,这里做硬约束。
+ // 3. 失败时不抛异常,返回工具失败结果,让上层继续下一轮决策。
+ toWeek, ok := paramInt(call.Params, "to_week")
+ if !ok {
+ return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_week"}
+ }
+ if toWeek != currentWeek {
+ return entries, reactToolResult{
+ Tool: "Move",
+ Success: false,
+ Result: fmt.Sprintf("当前仅允许优化本周:worker_week=%d,目标周=%d", currentWeek, toWeek),
+ }
+ }
+ // 4. 若已配置全局窗口边界,再做“首尾不足一周”硬校验。
+ // 4.1 这样可避免把任务移动到窗口外的天数(例如起始周的起始日前、结束周的结束日后)。
+ // 4.2 窗口未启用时不阻断,保持兼容旧链路。
+ if window.Enabled {
+ toDay, ok := paramInt(call.Params, "to_day")
+ if !ok {
+ return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失:需要 to_day"}
+ }
+ allowed, dayFrom, dayTo := isDayWithinPlanningWindow(window, toWeek, toDay)
+ if !allowed {
+ return entries, reactToolResult{
+ Tool: "Move",
+ Success: false,
+ Result: fmt.Sprintf("目标日期超出排程窗口:W%d 仅允许 D%d-D%d,当前目标为 D%d", toWeek, dayFrom, dayTo, toDay),
+ }
+ }
+ }
+ return reactToolMove(entries, call.Params)
+ default:
+ return entries, reactToolResult{
+ Tool: tool,
+ Success: false,
+ Result: fmt.Sprintf("周级单步模式不支持工具: %s,仅允许 Move/Swap", tool),
+ }
+ }
+}
+
+// isDayWithinPlanningWindow 判断目标 week/day 是否落在窗口范围内。
+//
+// 返回值:
+// 1. allowed:是否允许;
+// 2. dayFrom/dayTo:该周允许的 day 区间(用于错误提示)。
+func isDayWithinPlanningWindow(window weeklyPlanningWindow, week int, day int) (allowed bool, dayFrom int, dayTo int) {
+ // 1. 窗口未启用时默认允许(调用方会跳过此分支,这里是兜底)。
+ if !window.Enabled {
+ return true, 1, 7
+ }
+ // 2. 先做周范围校验。
+ if week < window.StartWeek || week > window.EndWeek {
+ return false, 1, 7
+ }
+ // 3. 计算当前周允许的 day 边界。
+ from := 1
+ to := 7
+ if week == window.StartWeek {
+ from = window.StartDay
+ }
+ if week == window.EndWeek {
+ to = window.EndDay
+ }
+ if day < from || day > to {
+ return false, from, to
+ }
+ return true, from, to
+}
+
+// ── 参数提取辅助 ──
+
+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
+}
+
+// entryBlocksSuggested 判断某条目是否应阻塞 suggested 任务占位。
+//
+// 规则:
+// 1. suggested 任务永远阻塞(任务之间不能重叠);
+// 2. existing 条目按 BlockForSuggested 字段决定;
+// 3. 其余场景默认阻塞(保守策略,避免放出脏可用槽)。
+func entryBlocksSuggested(entry model.HybridScheduleEntry) bool {
+ if entry.Status == "suggested" {
+ return true
+ }
+ // existing 走显式字段语义。
+ if entry.Status == "existing" {
+ return entry.BlockForSuggested
+ }
+ // 未知状态兜底:按阻塞处理。
+ return true
+}
+
+// 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
+ }
+ // 1. 可嵌入且未占用的课程槽(BlockForSuggested=false)不参与冲突判断。
+ // 2. 这样可以避免把“水课可嵌入位”误判为硬冲突。
+ if !entryBlocksSuggested(e) {
+ 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 {
+ if !entryBlocksSuggested(e) {
+ continue
+ }
+ 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) {
+ return agentllm.ParseScheduleReactOutput(raw)
+}
+
+// truncate 截断字符串到指定长度。
+func truncate(s string, maxLen int) string {
+ if maxLen <= 0 {
+ return ""
+ }
+ runes := []rune(s)
+ if len(runes) <= maxLen {
+ return s
+ }
+ return string(runes[:maxLen]) + "..."
+}
diff --git a/backend/agent2/node/taskquery_test.go b/backend/agent2/node/taskquery_test.go
deleted file mode 100644
index 77d0835..0000000
--- a/backend/agent2/node/taskquery_test.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package agentnode
-
-import (
- "strings"
- "testing"
-
- agentllm "github.com/LoveLosita/smartflow/backend/agent2/llm"
- agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
-)
-
-// TestExtractExplicitLimitFromUser_Number 验证阿拉伯数字条数可以被正确提取。
-func TestExtractExplicitLimitFromUser_Number(t *testing.T) {
- limit, ok := extractExplicitLimitFromUser("给我3个优先级低的任务")
- if !ok {
- t.Fatalf("期望识别到显式数量")
- }
- if limit != 3 {
- t.Fatalf("数量识别错误,期望=3 实际=%d", limit)
- }
-}
-
-// TestExtractExplicitLimitFromUser_ChineseNumber 验证中文数字也可以被正确提取。
-func TestExtractExplicitLimitFromUser_ChineseNumber(t *testing.T) {
- limit, ok := extractExplicitLimitFromUser("前五个简单任务给我看看")
- if !ok {
- t.Fatalf("期望识别到中文数量")
- }
- if limit != 5 {
- t.Fatalf("数量识别错误,期望=5 实际=%d", limit)
- }
-}
-
-// TestExtractExplicitLimitFromUser_LaiYiGe 验证“来一个”这类口语表达也能命中数量提取。
-func TestExtractExplicitLimitFromUser_LaiYiGe(t *testing.T) {
- limit, ok := extractExplicitLimitFromUser("来一个我的简单任务")
- if !ok {
- t.Fatalf("期望识别到‘来一个’的显式数量")
- }
- if limit != 1 {
- t.Fatalf("数量识别错误,期望=1 实际=%d", limit)
- }
-}
-
-// TestBuildTaskQueryFinalReply_RespectsLimit 验证最终回复严格遵守 plan.limit。
-func TestBuildTaskQueryFinalReply_RespectsLimit(t *testing.T) {
- items := []agentmodel.TaskQueryItem{
- {ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"},
- {ID: 2, Title: "任务2", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-17 10:00"},
- {ID: 3, Title: "任务3", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-18 10:00"},
- }
- reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 2}, "好的")
- if !strings.Contains(reply, "整理了 2 条任务") {
- t.Fatalf("回复未体现 limit=2,reply=%s", reply)
- }
- if strings.Contains(reply, "3. ") {
- t.Fatalf("回复中不应出现第 3 条,reply=%s", reply)
- }
-}
-
-// TestBuildTaskQueryFinalReply_NoDuplicateList 验证 llmReply 自带列表时不会与后端列表重复拼接。
-func TestBuildTaskQueryFinalReply_NoDuplicateList(t *testing.T) {
- items := []agentmodel.TaskQueryItem{{ID: 1, Title: "任务1", PriorityLabel: "简单不重要", DeadlineAt: "2026-03-16 10:00"}}
- llmReply := "以下是你的任务:\n#1 任务1"
- reply := buildTaskQueryFinalReply(items, agentmodel.TaskQueryPlan{Limit: 1}, llmReply)
- if strings.Contains(reply, "以下是你的任务") {
- t.Fatalf("不应保留 llm 列表头,reply=%s", reply)
- }
- if !strings.Contains(reply, "整理了 1 条任务") {
- t.Fatalf("应保留后端确定性列表头,reply=%s", reply)
- }
-}
-
-// TestApplyRetryPatch_RespectExplicitLimit 验证显式数量存在时,反思补丁不能覆盖 limit。
-func TestApplyRetryPatch_RespectExplicitLimit(t *testing.T) {
- plan := agentmodel.TaskQueryPlan{Limit: 1, SortBy: "deadline", Order: "asc"}
- limit := 10
- next := applyRetryPatch(plan, agentllm.TaskQueryRetryPatch{Limit: &limit}, 1)
- if next.Limit != 1 {
- t.Fatalf("显式数量锁应生效,期望=1 实际=%d", next.Limit)
- }
-}
diff --git a/backend/agent2/node/taskquery_tool_test.go b/backend/agent2/node/taskquery_tool_test.go
deleted file mode 100644
index 97dfed1..0000000
--- a/backend/agent2/node/taskquery_tool_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package agentnode
-
-import "testing"
-
-// TestNormalizeTaskQueryToolInput_Default 验证空输入会回填默认查询参数。
-func TestNormalizeTaskQueryToolInput_Default(t *testing.T) {
- req, err := normalizeTaskQueryToolInput(nil)
- if err != nil {
- t.Fatalf("不应报错: %v", err)
- }
- if req.SortBy != "deadline" || req.Order != "asc" || req.Limit != 5 || req.IncludeCompleted {
- t.Fatalf("默认值异常: %+v", req)
- }
-}
-
-// TestNormalizeTaskQueryToolInput_InvalidQuadrant 验证 quadrant 越界时会被拦截。
-func TestNormalizeTaskQueryToolInput_InvalidQuadrant(t *testing.T) {
- invalid := 6
- _, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{Quadrant: &invalid})
- if err == nil {
- t.Fatalf("期望 quadrant 越界时报错")
- }
-}
-
-// TestNormalizeTaskQueryToolInput_DateRange 验证时间上下界可被正确解析。
-func TestNormalizeTaskQueryToolInput_DateRange(t *testing.T) {
- req, err := normalizeTaskQueryToolInput(&TaskQueryToolInput{
- DeadlineAfter: "2026-03-01 08:00",
- DeadlineBefore: "2026-03-31",
- })
- if err != nil {
- t.Fatalf("不应报错: %v", err)
- }
- if req.DeadlineAfter == nil || req.DeadlineBefore == nil {
- t.Fatalf("时间上下界不应为空: %+v", req)
- }
- if req.DeadlineAfter.After(*req.DeadlineBefore) {
- t.Fatalf("时间上下界关系异常: after=%v before=%v", req.DeadlineAfter, req.DeadlineBefore)
- }
-}
diff --git a/backend/agent2/prompt/schedule.go b/backend/agent2/prompt/schedule.go
index 487fdc3..610a635 100644
--- a/backend/agent2/prompt/schedule.go
+++ b/backend/agent2/prompt/schedule.go
@@ -1,24 +1,172 @@
package agentprompt
-import (
- "fmt"
- "strings"
+const (
+ // SchedulePlanIntentPrompt 用于 plan 节点:从用户输入提取排程意图与约束。
+ //
+ // 职责边界:
+ // 1. 负责把自然语言转成结构化 JSON,供后端节点分流与执行;
+ // 2. 负责抽取 task_class_ids / strategy / task_tags 等关键字段;
+ // 3. 不负责做排程计算,不负责做工具调用。
+ SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。
+请根据用户输入,提取排程意图与约束条件。
+
+必须完成以下任务:
+1) 用一句话概括用户的排程意图(intent)。
+2) 提取所有硬约束(constraints),如“早八不排”“周末休息”等。
+3) 如果用户明确提到了任务类名称或ID,输出 task_class_ids(整数数组);否则输出空数组 []。
+4) 兼容字段 task_class_id:若 task_class_ids 非空,可填第一个ID;若无法判断填 -1。
+5) 判断排程策略 strategy:均匀分布选 "steady",集中突击选 "rapid",默认 "steady"。
+6) 尝试给任务打认知标签 task_tags(可选):
+ - 推荐键:task_item_id(字符串形式,例如 "12")
+ - 兼容键:任务名称(例如 "高数复习")
+ - 值只能是:High-Logic / Memory / Review / General
+ - 如果无法判断,输出空对象 {}
+7) 判定本轮是否要求“强制重排” restart:
+ - 用户明确表达“重新排/推倒重来/忽略之前方案/全部重来”时,restart=true;
+ - 否则 restart=false。
+8) 判定微调力度 adjustment_scope(small / medium / large):
+ - small:局部微调,通常只改少量时段,不需要重建全局。
+ - medium:中等调整,需要周级再平衡,但不必全量重粗排。
+ - large:大范围调整,或首次创建排程,或约束变化很大,需要完整重排。
+9) 输出 reason(简短中文理由,<=30字)与 confidence(0~1)。
+
+输出要求:
+- 仅输出 JSON,不要 markdown,不要解释。
+- 格式如下:
+{
+ "intent": "用户排程意图摘要",
+ "constraints": ["约束1", "约束2"],
+ "task_class_ids": [12, 13],
+ "task_class_id": 12,
+ "strategy": "steady",
+ "task_tags": {"12":"High-Logic","英语阅读":"Memory"},
+ "restart": false,
+ "adjustment_scope": "medium",
+ "reason": "本次只调整局部时段",
+ "confidence": 0.86
+}`
+
+ // SchedulePlanDailyReactPrompt 用于 daily_refine 节点。
+ //
+ // 职责边界:
+ // 1. 只处理“单天”数据,避免跨天决策污染;
+ // 2. 通过工具调用做小步调整;
+ // 3. 不负责周级配平,不负责最终总结。
+ SchedulePlanDailyReactPrompt = `你是 SmartFlow 日内排程优化器。
+
+你将收到一天内的日程安排(JSON 数组),其中:
+- status="existing":已确定的课程或任务,不可移动
+- status="suggested":粗排算法建议的学习任务,你可以调整
+- context_tag:任务认知类型(High-Logic/Memory/Review/General)
+
+你的目标是优化这一天内 suggested 任务的时间安排。
+
+## 优化原则
+1. 上下文切换成本:相同 context_tag 的任务尽量相邻,减少认知切换。
+2. 时段适配性:
+ - 第1-4节(上午):适合 High-Logic(数学、编程)
+ - 第5-8节(下午):适合中等强度(专业课、阅读)
+ - 第9-12节(晚间):适合 Memory 和 Review
+3. 学习效率曲线:避免连续超过 4 节高强度学习。
+4. 与 existing 条目衔接:避免高强度课程后立刻接高强度任务。
+
+## 可用工具
+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}}]}
+
+完成优化时:
+{"done":true,"summary":"简要说明优化理由"}
+
+重要:只修改 suggested 任务,不要尝试移动 existing 条目。`
+
+ // SchedulePlanWeeklyReactPrompt 用于 weekly_refine 节点。
+ //
+ // 设计重点:
+ // 1. 采用“单步动作”模式:每轮只做一个动作(Move/Swap)或直接 done;
+ // 2. 显式区分总预算与有效预算,避免模型对“次数扣减”产生困惑;
+ // 3. 明确“输入数据已过后端硬校验”,避免模型把合法嵌入误判为冲突;
+ // 4. 工具失败结果会回传到下一轮,模型只需“走一步看一步”。
+ SchedulePlanWeeklyReactPrompt = `你是 SmartFlow 周级排程配平器。
+
+单日内的排程已优化完毕,你当前只负责“单周微调”。
+
+## 数据可靠性前提(必须接受)
+1. 你收到的混合日程 JSON 已经过后端硬冲突检查。
+2. 如果看到课程与任务在同一节次重叠,这表示“任务嵌入课程”的合法状态,不是异常。
+3. 你不需要再次判断“输入本身是否冲突”,只需要在这个可信基线上进行优化。
+4. 工具内部会做可用性与冲突校验;你无需额外调用“检查可用性工具”。
+5. 字段语义补充:
+ - existing 条目的 block_for_suggested=false:该课程格子允许嵌入 suggested 任务;
+ - suggested 条目的 block_for_suggested=true:表示该 suggested 本身会占位,防止被其他 suggested 再次重叠覆盖。
+
+## 预算语义(必须遵守,且必须严格区分)
+1. 总动作预算(剩余):{{action_total_remaining}}
+2. 总动作预算(固定):{{action_total_budget}}
+3. 总动作预算(已用):{{action_total_used}}
+4. 有效动作预算(剩余):{{action_effective_remaining}}
+5. 有效动作预算(固定):{{action_effective_budget}}
+6. 有效动作预算(已用):{{action_effective_used}}
+7. 规则:
+ - 每次工具调用(无论成功失败)都会消耗 1 次“总动作预算”;
+ - 仅当工具调用成功时,才会额外消耗 1 次“有效动作预算”。
+8. 你当前看到的是“剩余额度”,不是“总额度”,额度减少是前序动作正常消耗。
+
+## 约束
+1. 只允许在当前周内优化(禁止跨周移动)。
+2. 每次回复只能做一件事:要么调用 1 个工具,要么 done。
+3. 严格遵守用户约束(如有)。
+4. 每个任务最多变动一次位置。
+
+## 优化目标
+1. 疲劳度均衡:避免某一天堆积过多高强度任务(context_tag=High-Logic)。
+2. 间隔重复:同一科目任务适当分散到不同天。
+3. 科目多样性:尽量避免单一任务类型连续多天占据相同时段。
+4. 总量均衡:各天 suggested 数量大致均匀。
+
+## 执行节奏(降低无效思考)
+1. 想一步做一步:本轮只做“一个最有价值动作”。
+2. 不要一次规划多步;上一轮工具结果会传给下一轮,你可以继续接力。
+3. 如果当前方案已经足够好,直接 done,不要空转。
+4. 禁止输出多个工具调用;如果需要连续调整,请分多轮逐步完成。
+
+## 可用工具
+1. Move — 将一个 suggested 任务移动到当前周的另一天/时段
+ 参数:task_item_id, to_week, to_day, to_section_from, to_section_to
+ 注意:节次跨度必须与原任务一致
+2. Swap — 交换两个 suggested 任务的时间
+ 参数:task_a, task_b(task_item_id)
+
+## 输出格式(严格 JSON,不要 markdown)
+调用工具时(注意:tool_calls 里只能有 1 个元素):
+{"tool_calls":[{"tool":"Move","params":{"task_item_id":10,"to_week":2,"to_day":3,"to_section_from":5,"to_section_to":6}}]}
+
+完成优化时:
+{"done":true,"summary":"简要说明做了哪些跨天调整及理由"}`
+
+ // SchedulePlanFinalCheckPrompt 用于 final_check 节点的人性化总结。
+ //
+ // 职责边界:
+ // 1. 只做读数据总结,不参与工具调用与状态修改;
+ // 2. 输出面向用户的自然语言;
+ // 3. 失败由上层兜底文案处理。
+ SchedulePlanFinalCheckPrompt = `你是 SmartFlow 排程方案总结专家。
+你的任务是为用户生成一段友好、自然的排程总结。
+
+要求:
+1. 用 2-3 句话概括方案亮点。
+2. 提及具体时间安排特征(如“上午安排高强度任务”“周末留出缓冲”)。
+3. 若用户有约束,说明方案如何满足这些约束。
+4. 输入里会包含“周级动作日志”,请结合日志说明优化过程的价值(例如更均衡、冲突更少、切换更顺)。
+5. 语气温暖自然。
+6. 只输出纯文本,不要输出 JSON。`
)
-
-const scheduleSystemPrompt = `
-你是 SmartFlow 的智能排程助手。
-你的职责是把用户的排程目标转成结构化计划,并与后端粗排/硬校验能力配合完成排程。
-
-当前 agent2 还没正式迁移 schedule 相关旧代码,
-因此这里先定义 prompt 收口点,确保后面迁移时不会再把大段提示词散写回 nodes.go。
-`
-
-// BuildScheduleSystemPrompt 返回排程系统提示词骨架。
-func BuildScheduleSystemPrompt() string {
- return strings.TrimSpace(scheduleSystemPrompt)
-}
-
-// BuildScheduleUserPrompt 构造排程用户提示词骨架。
-func BuildScheduleUserPrompt(nowText, userInput string) string {
- return fmt.Sprintf("当前时间(北京时间,精确到分钟):%s\n用户请求:%s", strings.TrimSpace(nowText), strings.TrimSpace(userInput))
-}
diff --git a/backend/service/agentsvc/agent_meta_test.go b/backend/service/agentsvc/agent_meta_test.go
deleted file mode 100644
index 72cdcb2..0000000
--- a/backend/service/agentsvc/agent_meta_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package agentsvc
-
-import (
- "strings"
- "testing"
-
- "github.com/cloudwego/eino/schema"
-)
-
-// TestNormalizeConversationTitle
-// 目的:确保标题清洗逻辑能去掉引号/前缀并裁剪到上限长度。
-func TestNormalizeConversationTitle(t *testing.T) {
- raw := "标题:\"明天上午去机场接人并顺路取快递,记得提前出门\""
- got := normalizeConversationTitle(raw)
- if strings.HasPrefix(got, "标题") {
- t.Fatalf("标题前缀未清洗,got=%s", got)
- }
- if len([]rune(got)) > conversationTitleMaxChars {
- t.Fatalf("标题长度超限,got=%s", got)
- }
- if strings.TrimSpace(got) == "" {
- t.Fatalf("清洗后标题不应为空")
- }
-}
-
-// TestBuildConversationTitleUserPrompt
-// 目的:确保 prompt 构造时能正确标注用户/助手角色并包含有效内容。
-func TestBuildConversationTitleUserPrompt(t *testing.T) {
- msgs := []*schema.Message{
- {Role: schema.User, Content: "明天早上九点去机场接人"},
- {Role: schema.Assistant, Content: "收到,我帮你记下了。"},
- }
- prompt := buildConversationTitleUserPrompt(msgs)
- if !strings.Contains(prompt, "用户:明天早上九点去机场接人") {
- t.Fatalf("prompt 未包含用户内容,prompt=%s", prompt)
- }
- if !strings.Contains(prompt, "助手:收到,我帮你记下了。") {
- t.Fatalf("prompt 未包含助手内容,prompt=%s", prompt)
- }
-}
diff --git a/backend/service/agentsvc/agent_quick_note_route_test.go b/backend/service/agentsvc/agent_quick_note_route_test.go
deleted file mode 100644
index b42acda..0000000
--- a/backend/service/agentsvc/agent_quick_note_route_test.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package agentsvc
-
-import (
- "strings"
- "testing"
-
- agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
- agentrouter "github.com/LoveLosita/smartflow/backend/agent2/router"
-)
-
-// TestParseQuickNoteRouteControlTag_QuickNote
-// 目的:
-// 1. 验证旧 quick note 兼容入口仍然可以解析控制码;
-// 2. 验证旧 action=quick_note 会被统一映射到新动作 quick_note_create;
-// 3. 验证 reason 仍然会被保留下来,方便上层做阶段提示与排障。
-func TestParseQuickNoteRouteControlTag_QuickNote(t *testing.T) {
- nonce := "abc123nonce"
- raw := `
-用户明确在请求未来提醒`
-
- decision, err := agentrouter.ParseQuickNoteRouteControlTag(raw, nonce)
- if err != nil {
- t.Fatalf("解析失败: %v", err)
- }
- if decision == nil {
- t.Fatalf("decision 不应为空")
- }
- if decision.Action != agentrouter.ActionQuickNoteCreate {
- t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionQuickNoteCreate, decision.Action)
- }
- if strings.TrimSpace(decision.Reason) == "" {
- t.Fatalf("reason 不应为空")
- }
-}
-
-// TestParseRouteControlTag_TaskQuery
-// 目的:验证通用分流控制码在 action=task_query 时可以被稳定解析。
-func TestParseRouteControlTag_TaskQuery(t *testing.T) {
- nonce := "taskquerynonce"
- raw := `
-用户在查最紧急任务`
-
- decision, err := agentrouter.ParseRouteControlTag(raw, nonce)
- if err != nil {
- t.Fatalf("解析失败: %v", err)
- }
- if decision == nil {
- t.Fatalf("decision 不应为空")
- }
- if decision.Action != agentrouter.ActionTaskQuery {
- t.Fatalf("action 解析错误,期望=%s 实际=%s", agentrouter.ActionTaskQuery, decision.Action)
- }
-}
-
-// TestParseQuickNoteRouteControlTag_NonceMismatch
-// 目的:确保 nonce 不匹配时直接报错,避免把别的请求控制码误判成当前请求。
-func TestParseQuickNoteRouteControlTag_NonceMismatch(t *testing.T) {
- raw := ``
- if _, err := agentrouter.ParseQuickNoteRouteControlTag(raw, "expectednonce"); err == nil {
- t.Fatalf("期望 nonce 不匹配时报错,但未报错")
- }
-}
-
-// TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID
-// 目的:
-// 1. 即使状态被错误标记为 Persisted=true;
-// 2. 只要没有有效 task_id,就不能回成功文案;
-// 3. 避免出现“回复成功但库里没数据”的假成功体验。
-func TestBuildQuickNoteFinalReply_NoFalseSuccessWithoutTaskID(t *testing.T) {
- state := &agentmodel.QuickNoteState{
- Persisted: true,
- PersistedTaskID: 0,
- ExtractedTitle: "去下馆子",
- }
-
- reply := buildQuickNoteFinalReply(nil, nil, "我今天晚上6点要去下馆子,记得喊我", state)
- if strings.Contains(reply, "给你安排上了") || strings.Contains(reply, "已安排") {
- t.Fatalf("不应返回成功文案,实际回复=%s", reply)
- }
-}
-
-// TestBuildQuickNoteFinalReply_UseExtractedBanter
-// 目的:
-// 1. 当聚合规划阶段已经产出 banter 时,最终回复应直接复用;
-// 2. 避免为了润色再次调用模型,增加不必要时延。
-func TestBuildQuickNoteFinalReply_UseExtractedBanter(t *testing.T) {
- state := &agentmodel.QuickNoteState{
- Persisted: true,
- PersistedTaskID: 12,
- ExtractedTitle: "明天去取快递",
- ExtractedPriority: 2,
- ExtractedBanter: "取件路上注意保暖,别被风吹懵了。",
- }
-
- reply := buildQuickNoteFinalReply(nil, nil, "明天上午12点我要去取快递,到时候记得q我", state)
- if !strings.Contains(reply, "取件路上注意保暖") {
- t.Fatalf("期望复用 ExtractedBanter,实际回复=%s", reply)
- }
-}
diff --git a/backend/service/agentsvc/agent_schedule_plan.go b/backend/service/agentsvc/agent_schedule_plan.go
index 58d4e4b..eecc949 100644
--- a/backend/service/agentsvc/agent_schedule_plan.go
+++ b/backend/service/agentsvc/agent_schedule_plan.go
@@ -6,7 +6,9 @@ import (
"log"
"strings"
- "github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
+ agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
+ agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
+ agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/pkg"
@@ -107,7 +109,7 @@ func (s *AgentService) runSchedulePlanFlow(
// 4. 执行 graph 主流程。
// 4.1 这里只负责参数拼装与调用,不在 service 层重复实现 graph 节点逻辑。
// 4.2 并发度/预算从配置注入,避免把调优参数写死在代码中。
- state := scheduleplan.NewSchedulePlanState(traceID, userID, chatID)
+ state := agentmodel.NewSchedulePlanState(traceID, userID, chatID)
// 4.3 连续对话微调注入:
// 4.3.1 若命中上轮预览,则把任务类/混合条目/分配结果注入 state;
// 4.3.2 这样 rough_build 可按需复用旧底板,避免每轮都重新粗排。
@@ -118,10 +120,10 @@ func (s *AgentService) runSchedulePlanFlow(
state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems)
state.PreviousCandidatePlans = cloneWeekSchedules(previousPreview.CandidatePlans)
}
- finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{
+ finalState, runErr := agentgraph.RunSchedulePlanGraph(ctx, agentnode.SchedulePlanGraphRunInput{
Model: selectedModel,
State: state,
- Deps: scheduleplan.SchedulePlanToolDeps{
+ Deps: agentnode.SchedulePlanToolDeps{
SmartPlanningMultiRaw: s.SmartPlanningMultiRawFunc,
HybridScheduleWithPlanMulti: s.HybridScheduleWithPlanMultiFunc,
ResolvePlanningWindow: s.ResolvePlanningWindowFunc,
@@ -155,6 +157,6 @@ func (s *AgentService) runSchedulePlanFlow(
// 6. 旁路写入排程预览缓存(结构化 JSON),给查询接口拉取。
// 6.1 失败只记日志,不影响本次对话回复;
// 6.2 成功后前端可通过 conversation_id 获取 candidate_plans。
- s.saveSchedulePlanPreview(ctx, userID, chatID, finalState)
+ s.saveSchedulePlanPreviewAgent2(ctx, userID, chatID, finalState)
return reply, nil
}
diff --git a/backend/service/agentsvc/agent_schedule_preview.go b/backend/service/agentsvc/agent_schedule_preview.go
index b634f14..7942384 100644
--- a/backend/service/agentsvc/agent_schedule_preview.go
+++ b/backend/service/agentsvc/agent_schedule_preview.go
@@ -8,6 +8,8 @@ import (
"time"
"github.com/LoveLosita/smartflow/backend/agent/scheduleplan"
+ agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
+ agentshared "github.com/LoveLosita/smartflow/backend/agent2/shared"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
)
@@ -69,6 +71,62 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int,
}
}
+// saveSchedulePlanPreviewAgent2 把 agent2 的 schedule_plan 结果写入 Redis 预览与 MySQL 快照。
+//
+// 职责边界:
+// 1. 负责承接“新 agent2 首次排程链路”的最终状态;
+// 2. 负责沿用现有预览缓存/状态快照协议,保证查询接口与 refine 读取逻辑不需要跟着重写;
+// 3. 不负责 refine 状态转换,refine 仍继续走旧链路的 saveSchedulePlanPreview。
+func (s *AgentService) saveSchedulePlanPreviewAgent2(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) {
+ // 1. 基础前置校验:state 为空时直接返回,避免写入半成品快照。
+ if s == nil || finalState == nil {
+ return
+ }
+ normalizedChatID := strings.TrimSpace(chatID)
+ if normalizedChatID == "" {
+ return
+ }
+
+ // 2. 组装缓存快照。
+ // 2.1 summary 优先取 final summary,空值时使用统一兜底文案;
+ // 2.2 candidate_plans / hybrid_entries / allocated_items 统一深拷贝,避免缓存与 graph state 共用底层切片;
+ // 2.3 generated_at 用于前端判断“当前预览是否为最新方案”。
+ summary := strings.TrimSpace(finalState.FinalSummary)
+ if summary == "" {
+ summary = "排程流程已完成,但未生成结果摘要。"
+ }
+ preview := &model.SchedulePlanPreviewCache{
+ UserID: userID,
+ ConversationID: normalizedChatID,
+ TraceID: strings.TrimSpace(finalState.TraceID),
+ Summary: summary,
+ CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans),
+ TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...),
+ HybridEntries: cloneHybridEntries(finalState.HybridEntries),
+ AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems),
+ GeneratedAt: time.Now(),
+ }
+
+ // 3. 先写 Redis 预览,保证前端查询接口能立即读取结构化结果。
+ // 3.1 Redis 是“快路径”;
+ // 3.2 失败只记录日志,不中断聊天主链路。
+ if s.cacheDAO != nil {
+ if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil {
+ log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err)
+ }
+ }
+
+ // 4. 同步写 MySQL 快照,保证 Redis 失效后仍能恢复预览与连续微调上下文。
+ // 4.1 这里继续保持“同步写库”策略,因为下一轮微调对快照读取是强实时依赖;
+ // 4.2 写库失败只打日志,不阻断本轮给用户的文本回复。
+ if s.repo != nil {
+ snapshot := buildSchedulePlanSnapshotFromAgent2State(userID, normalizedChatID, finalState)
+ if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil {
+ log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err)
+ }
+ }
+}
+
// GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。
//
// 职责边界:
@@ -137,62 +195,17 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c
// cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。
func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule {
- if len(src) == 0 {
- return nil
- }
- dst := make([]model.UserWeekSchedule, 0, len(src))
- for _, week := range src {
- eventsCopy := make([]model.WeeklyEventBrief, len(week.Events))
- copy(eventsCopy, week.Events)
- dst = append(dst, model.UserWeekSchedule{
- Week: week.Week,
- Events: eventsCopy,
- })
- }
- return dst
+ return agentshared.CloneWeekSchedules(src)
}
// cloneHybridEntries 深拷贝混合条目切片,避免缓存/状态之间相互污染。
func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry {
- if len(src) == 0 {
- return nil
- }
- dst := make([]model.HybridScheduleEntry, len(src))
- copy(dst, src)
- return dst
+ return agentshared.CloneHybridEntries(src)
}
// cloneTaskClassItems 深拷贝任务块切片(包含指针字段),避免跨请求引用共享。
func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem {
- if len(src) == 0 {
- return nil
- }
- dst := make([]model.TaskClassItem, 0, len(src))
- for _, item := range src {
- copied := item
- if item.CategoryID != nil {
- v := *item.CategoryID
- copied.CategoryID = &v
- }
- if item.Order != nil {
- v := *item.Order
- copied.Order = &v
- }
- if item.Content != nil {
- v := *item.Content
- copied.Content = &v
- }
- if item.Status != nil {
- v := *item.Status
- copied.Status = &v
- }
- if item.EmbeddedTime != nil {
- t := *item.EmbeddedTime
- copied.EmbeddedTime = &t
- }
- dst = append(dst, copied)
- }
- return dst
+ return agentshared.CloneTaskClassItems(src)
}
// buildSchedulePlanSnapshotFromState 把 graph 运行结果映射成可持久化快照 DTO。
@@ -224,6 +237,35 @@ func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *s
}
}
+// buildSchedulePlanSnapshotFromAgent2State 把 agent2 的排程状态映射成可持久化快照 DTO。
+//
+// 调用目的:
+// 1. 这轮只迁移 schedule_plan,不动 refine;
+// 2. 因此 preview/快照协议继续复用老结构,但要补一个“agent2 state -> snapshot DTO”的映射层;
+// 3. 这样可以做到:计划创建链路切到 agent2,而 refine / 预览查询链路暂时无需大改。
+func buildSchedulePlanSnapshotFromAgent2State(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot {
+ if st == nil {
+ return nil
+ }
+ return &model.SchedulePlanStateSnapshot{
+ UserID: userID,
+ ConversationID: conversationID,
+ StateVersion: model.SchedulePlanStateVersionV1,
+ TaskClassIDs: append([]int(nil), st.TaskClassIDs...),
+ Constraints: append([]string(nil), st.Constraints...),
+ HybridEntries: cloneHybridEntries(st.HybridEntries),
+ AllocatedItems: cloneTaskClassItems(st.AllocatedItems),
+ CandidatePlans: cloneWeekSchedules(st.CandidatePlans),
+ UserIntent: strings.TrimSpace(st.UserIntent),
+ Strategy: strings.TrimSpace(st.Strategy),
+ AdjustmentScope: strings.TrimSpace(st.AdjustmentScope),
+ RestartRequested: st.RestartRequested,
+ FinalSummary: strings.TrimSpace(st.FinalSummary),
+ Completed: st.Completed,
+ TraceID: strings.TrimSpace(st.TraceID),
+ }
+}
+
// snapshotToSchedulePlanPreviewCache 把 MySQL 快照转换为 Redis 预览缓存结构。
func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache {
if snapshot == nil {
diff --git a/backend/service/agentsvc/agent_schedule_refine_test.go b/backend/service/agentsvc/agent_schedule_refine_test.go
deleted file mode 100644
index b50f9ce..0000000
--- a/backend/service/agentsvc/agent_schedule_refine_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package agentsvc
-
-import (
- "testing"
-
- "github.com/LoveLosita/smartflow/backend/agent/schedulerefine"
-)
-
-func TestShouldPersistScheduleRefinePreviewSkipsFailedCompositeRoute(t *testing.T) {
- st := &schedulerefine.ScheduleRefineState{
- CompositeRouteSucceeded: true,
- HardCheck: schedulerefine.HardCheckReport{
- PhysicsPassed: true,
- OrderPassed: true,
- IntentPassed: false,
- },
- }
-
- if shouldPersistScheduleRefinePreview(st) {
- t.Fatalf("期望复合分支终审失败时不覆盖上一版预览")
- }
-}
-
-func TestShouldPersistScheduleRefinePreviewAllowsPassedCompositeRoute(t *testing.T) {
- st := &schedulerefine.ScheduleRefineState{
- CompositeRouteSucceeded: true,
- HardCheck: schedulerefine.HardCheckReport{
- PhysicsPassed: true,
- OrderPassed: true,
- IntentPassed: true,
- },
- }
-
- if !shouldPersistScheduleRefinePreview(st) {
- t.Fatalf("期望复合分支终审通过时允许覆盖预览")
- }
-}
-
-func TestShouldPersistScheduleRefinePreviewKeepsReactPathBehavior(t *testing.T) {
- st := &schedulerefine.ScheduleRefineState{
- CompositeRouteSucceeded: false,
- HardCheck: schedulerefine.HardCheckReport{
- PhysicsPassed: true,
- OrderPassed: true,
- IntentPassed: false,
- },
- }
-
- if !shouldPersistScheduleRefinePreview(st) {
- t.Fatalf("期望非复合直出分支继续沿用原有预览持久化策略")
- }
-}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9300ac7..b12c92a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,11 +11,14 @@
"@vue/shared": "^3.5.0",
"axios": "^1.8.0",
"element-plus": "^2.9.0",
+ "highlight.js": "^11.11.1",
+ "markdown-it": "^14.1.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
+ "@types/markdown-it": "^14.1.2",
"@types/node": "^22.10.0",
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.7.0",
@@ -824,6 +827,13 @@
"win32"
]
},
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
@@ -835,16 +845,36 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/lodash": "*"
}
},
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1094,6 +1124,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1167,6 +1203,18 @@
"vue": "^3.3.0"
}
},
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -1454,17 +1502,37 @@
"he": "bin/he"
}
},
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -1477,6 +1545,29 @@
"lodash-es": "*"
}
},
+ "node_modules/markdown-it": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
+ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "license": "MIT"
+ },
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -1547,6 +1638,15 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -1588,6 +1688,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -1601,6 +1702,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1609,6 +1711,12 @@
"node": ">=14.17"
}
},
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -1622,6 +1730,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -1835,6 +1944,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -1938,6 +2048,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
diff --git a/frontend/package.json b/frontend/package.json
index 0ddae61..d72f22b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,11 +12,14 @@
"@vue/shared": "^3.5.0",
"axios": "^1.8.0",
"element-plus": "^2.9.0",
+ "highlight.js": "^11.11.1",
+ "markdown-it": "^14.1.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
+ "@types/markdown-it": "^14.1.2",
"@types/node": "^22.10.0",
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "^5.7.0",
diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue
index f6091f0..8922612 100644
--- a/frontend/src/components/dashboard/AssistantPanel.vue
+++ b/frontend/src/components/dashboard/AssistantPanel.vue
@@ -42,6 +42,19 @@ interface StreamEventPayload {
error?: StreamErrorPayload
}
+type ModelType = 'worker' | 'strategist'
+
+const props = withDefaults(
+ defineProps<{
+ initialHistoryWidth?: number
+ viewMode?: 'embedded' | 'standalone'
+ }>(),
+ {
+ initialHistoryWidth: 228,
+ viewMode: 'embedded',
+ },
+)
+
const authStore = useAuthStore()
const assistantBodyRef = ref(null)
@@ -52,10 +65,10 @@ const conversationLoadingMore = ref(false)
const chatLoading = ref(false)
const historyExpanded = ref(true)
const selectedConversationId = ref('')
-const selectedModel = ref<'worker' | 'strategist'>('worker')
+const selectedModel = ref('worker')
const thinkingEnabled = ref(false)
const messageInput = ref('')
-const historyPanelWidth = ref(228)
+const historyPanelWidth = ref(props.initialHistoryWidth)
const activeStreamingMessageId = ref('')
const conversationPage = ref(1)
@@ -69,6 +82,8 @@ const conversationMessagesMap = reactive>({})
const unavailableHistoryMap = reactive>({})
const thinkingMessageMap = reactive>({})
const reasoningCollapsedMap = reactive>({})
+const reasoningStartedAtMap = reactive>({})
+const reasoningDurationMap = reactive>({})
const quickActions = [
'帮我梳理今天最重要的三件事',
@@ -77,11 +92,23 @@ const quickActions = [
'给我一个更稳妥的推进方案',
]
-let messageScrollRaf = 0
+const MODEL_PREFERENCE_STORAGE_KEY = 'smartflow.assistant.model.byConversation.v1'
-const assistantBodyStyle = computed(() => ({
- '--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`,
-}))
+let messageScrollRaf = 0
+let reasoningTicker = 0
+const reasoningDisplayNow = ref(Date.now())
+
+const isStandaloneMode = computed(() => props.viewMode === 'standalone')
+
+const assistantBodyStyle = computed(() => {
+ if (isStandaloneMode.value) {
+ return {}
+ }
+
+ return {
+ '--assistant-history-width': `${historyExpanded.value ? historyPanelWidth.value : 68}px`,
+ }
+})
const selectedConversation = computed(() =>
conversationList.value.find((item) => item.conversation_id === selectedConversationId.value),
@@ -136,6 +163,86 @@ const shouldShowHistoryFallback = computed(() => {
)
})
+function isModelType(value: unknown): value is ModelType {
+ return value === 'worker' || value === 'strategist'
+}
+
+function loadModelPreferenceMap() {
+ if (typeof window === 'undefined') {
+ return {} as Record
+ }
+
+ try {
+ const raw = window.localStorage.getItem(MODEL_PREFERENCE_STORAGE_KEY)
+ if (!raw) {
+ return {} as Record
+ }
+
+ const parsed = JSON.parse(raw) as unknown
+ const normalized: Record = {}
+ const entries = typeof parsed === 'object' && parsed ? Object.entries(parsed) : []
+
+ // 1. 只接收结构合法且值在白名单内的记录,避免脏数据把模型值污染为非法字符串。
+ // 2. 键为空字符串的记录直接丢弃,防止“新建会话未落库”场景写入无效索引。
+ // 3. 解析失败时回退为空对象,不阻塞聊天主流程。
+ for (const [conversationId, model] of entries) {
+ if (!conversationId || !isModelType(model)) {
+ continue
+ }
+ normalized[conversationId] = model
+ }
+
+ return normalized
+ } catch {
+ return {} as Record
+ }
+}
+
+const modelPreferenceMap = ref>(loadModelPreferenceMap())
+
+function persistModelPreferenceMap() {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ try {
+ window.localStorage.setItem(MODEL_PREFERENCE_STORAGE_KEY, JSON.stringify(modelPreferenceMap.value))
+ } catch {
+ // 1. 本地存储失败只影响“记忆体验”,不影响消息收发主链路。
+ // 2. 这里静默处理,避免用户每次切模型都被错误提示打断。
+ // 3. 若用户清理缓存或隐私模式限制写入,后续会自动退化为会话内临时选择。
+ }
+}
+
+function savePreferredModel(conversationId: string, model: ModelType) {
+ if (!conversationId || modelPreferenceMap.value[conversationId] === model) {
+ return
+ }
+
+ modelPreferenceMap.value = {
+ ...modelPreferenceMap.value,
+ [conversationId]: model,
+ }
+ persistModelPreferenceMap()
+}
+
+function resolvePreferredModel(conversationId: string) {
+ if (!conversationId) {
+ return null
+ }
+
+ return modelPreferenceMap.value[conversationId] ?? null
+}
+
+function applyPreferredModelForConversation(conversationId: string) {
+ const preferredModel = resolvePreferredModel(conversationId)
+ if (!preferredModel || preferredModel === selectedModel.value) {
+ return
+ }
+
+ selectedModel.value = preferredModel
+}
+
function ensureConversationBucket(conversationId: string) {
if (!conversationMessagesMap[conversationId]) {
conversationMessagesMap[conversationId] = []
@@ -196,6 +303,16 @@ function migrateConversationState(fromConversationId: string, toConversationId:
delete conversationMetaMap[fromConversationId]
}
+ if (modelPreferenceMap.value[fromConversationId]) {
+ const migratedModelMap = { ...modelPreferenceMap.value }
+ if (!migratedModelMap[toConversationId]) {
+ migratedModelMap[toConversationId] = migratedModelMap[fromConversationId]!
+ }
+ delete migratedModelMap[fromConversationId]
+ modelPreferenceMap.value = migratedModelMap
+ persistModelPreferenceMap()
+ }
+
const latestMap = new Map()
const deduplicated: ConversationListItem[] = []
const seen = new Set()
@@ -276,7 +393,7 @@ function normalizeHistoryMessage(message: ConversationHistoryMessage, index: num
reasoning: message.reasoning_content,
}
- thinkingMessageMap[id] = Boolean(message.reasoning_content?.trim())
+ thinkingMessageMap[id] = false
reasoningCollapsedMap[id] = Boolean(message.reasoning_content?.trim())
return normalized
}
@@ -293,6 +410,47 @@ function isThinkingMessage(message: AssistantMessage) {
return thinkingMessageMap[message.id] === true
}
+function markReasoningStart(message: AssistantMessage) {
+ if (reasoningStartedAtMap[message.id]) {
+ return
+ }
+
+ const parsedCreatedAt = Date.parse(message.createdAt)
+ reasoningStartedAtMap[message.id] = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now()
+}
+
+function markReasoningFinished(message: AssistantMessage) {
+ const startedAt = reasoningStartedAtMap[message.id]
+ if (startedAt && !reasoningDurationMap[message.id]) {
+ reasoningDurationMap[message.id] = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
+ }
+
+ thinkingMessageMap[message.id] = false
+}
+
+function getReasoningDurationSeconds(message: AssistantMessage) {
+ const fixedDuration = reasoningDurationMap[message.id]
+ if (fixedDuration) {
+ return fixedDuration
+ }
+
+ const startedAt = reasoningStartedAtMap[message.id]
+ if (!startedAt) {
+ return 0
+ }
+
+ return Math.max(1, Math.round((reasoningDisplayNow.value - startedAt) / 1000))
+}
+
+function getReasoningStatusLabel(message: AssistantMessage) {
+ const durationSeconds = getReasoningDurationSeconds(message)
+ if (durationSeconds > 0) {
+ return `已思考(用时 ${durationSeconds} 秒)`
+ }
+
+ return isStreamingMessage(message) && isThinkingMessage(message) ? '思考中' : '已思考'
+}
+
function isReasoningCollapsed(messageId: string) {
return reasoningCollapsedMap[messageId] === true
}
@@ -308,6 +466,10 @@ function shouldShowReasoningBox(message: AssistantMessage) {
)
}
+function shouldShowAnsweringIndicator(message: AssistantMessage) {
+ return isStreamingMessage(message) && !isThinkingMessage(message) && !message.content.trim()
+}
+
function scheduleScrollMessagesToBottom(smooth = false) {
if (messageScrollRaf) {
cancelAnimationFrame(messageScrollRaf)
@@ -395,7 +557,7 @@ function handleHistoryScroll(event: Event) {
// 3. 拖拽结束后统一解绑事件并清理全局样式,防止页面残留 col-resize 状态。
function startResizeHistoryPanel(event: PointerEvent) {
const body = assistantBodyRef.value
- if (!body || window.innerWidth <= 960 || !historyExpanded.value) {
+ if (isStandaloneMode.value || !body || window.innerWidth <= 960 || !historyExpanded.value) {
return
}
@@ -463,6 +625,7 @@ async function ensureConversationMeta(conversationId: string) {
async function selectConversation(conversationId: string) {
selectedConversationId.value = conversationId
+ applyPreferredModelForConversation(conversationId)
await Promise.allSettled([loadConversationMessages(conversationId), ensureConversationMeta(conversationId)])
scheduleScrollMessagesToBottom(false)
}
@@ -542,6 +705,9 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
}
if (payload === '[DONE]') {
+ if (isThinkingMessage(assistantMessage)) {
+ markReasoningFinished(assistantMessage)
+ }
activeStreamingMessageId.value = ''
reasoningCollapsedMap[assistantMessage.id] = true
return
@@ -562,16 +728,30 @@ function processSseBlock(block: string, assistantMessage: AssistantMessage) {
const delta = choice?.delta ?? parsed.delta ?? parsed
const finishReason = choice?.finish_reason ?? parsed.finish_reason ?? null
- if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content) {
+ if (
+ typeof delta?.reasoning_content === 'string' &&
+ delta.reasoning_content &&
+ !assistantMessage.content.trim()
+ ) {
+ markReasoningStart(assistantMessage)
assistantMessage.reasoning = `${assistantMessage.reasoning || ''}${delta.reasoning_content}`
thinkingMessageMap[assistantMessage.id] = true
}
if (typeof delta?.content === 'string' && delta.content) {
+ if (isThinkingMessage(assistantMessage)) {
+ // 1. 一旦正文开始回流,立刻结束“思考中”阶段,避免两个等待动画同时出现。
+ // 2. 这样视觉上始终保持“先思考,再输出正文”的单阶段感知。
+ // 3. 若后端偶发交错发送 reasoning/content,也以前端阶段机兜底,优先保证阅读一致性。
+ markReasoningFinished(assistantMessage)
+ }
assistantMessage.content += delta.content
}
if (finishReason) {
+ if (isThinkingMessage(assistantMessage)) {
+ markReasoningFinished(assistantMessage)
+ }
activeStreamingMessageId.value = ''
reasoningCollapsedMap[assistantMessage.id] = true
}
@@ -596,6 +776,7 @@ async function sendMessage(preset?: string) {
if (!selectedConversationId.value) {
selectedConversationId.value = draftConversationId
}
+ savePreferredModel(draftConversationId, selectedModel.value)
ensureConversationBucket(draftConversationId)
unavailableHistoryMap[draftConversationId] = false
@@ -691,7 +872,21 @@ watch(
},
)
+watch(
+ selectedModel,
+ (nextModel) => {
+ const conversationId = selectedConversationId.value
+ if (!conversationId) {
+ return
+ }
+ savePreferredModel(conversationId, nextModel)
+ },
+)
+
onMounted(async () => {
+ reasoningTicker = window.setInterval(() => {
+ reasoningDisplayNow.value = Date.now()
+ }, 1000)
await loadConversationListData(true)
})
@@ -699,12 +894,16 @@ onBeforeUnmount(() => {
if (messageScrollRaf) {
cancelAnimationFrame(messageScrollRaf)
}
+ if (reasoningTicker) {
+ window.clearInterval(reasoningTicker)
+ reasoningTicker = 0
+ }
document.body.classList.remove('dashboard-resizing')
})
-