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') })