package scheduleplan import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "github.com/LoveLosita/smartflow/backend/model" "github.com/cloudwego/eino-ext/components/model/ark" einoModel "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" ) // schedulePlanIntentOutput 是 plan 节点要求模型返回的结构化结果。 // // 兼容说明: // 1. 新主语义是 task_class_ids(数组); // 2. 为兼容旧 prompt/旧缓存输出,保留 task_class_id(单值)兜底解析; // 3. TaskTags 的 key 兼容两种写法: // 3.1 推荐:task_item_id(例如 "12"); // 3.2 兼容:任务名称(例如 "高数复习")。 type schedulePlanIntentOutput struct { 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"` } // 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, 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 st.clearPreviousPreviewContext() } // 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) { if chatModel == nil { return "", errors.New("schedule plan: model is nil") } messages := []*schema.Message{ schema.SystemMessage(systemPrompt), schema.UserMessage(userPrompt), } opts := []einoModel.Option{ ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}), einoModel.WithTemperature(0), } if maxTokens > 0 { opts = append(opts, einoModel.WithMaxTokens(maxTokens)) } resp, err := chatModel.Generate(ctx, messages, opts...) if err != nil { return "", err } if resp == nil { return "", errors.New("模型返回为空") } content := strings.TrimSpace(resp.Content) if content == "" { return "", errors.New("模型返回内容为空") } return content, nil } // parseScheduleJSON 解析模型返回的 JSON 内容。 // // 兼容策略: // 1. 兼容 ```json ... ``` 包裹; // 2. 兼容模型在 JSON 前后带解释文本(提取最外层对象)。 func parseScheduleJSON[T any](raw string) (*T, error) { clean := strings.TrimSpace(raw) if clean == "" { return nil, errors.New("empty response") } if strings.HasPrefix(clean, "```") { clean = strings.TrimPrefix(clean, "```json") clean = strings.TrimPrefix(clean, "```") clean = strings.TrimSuffix(clean, "```") clean = strings.TrimSpace(clean) } var out T if err := json.Unmarshal([]byte(clean), &out); err == nil { return &out, nil } start := strings.Index(clean, "{") end := strings.LastIndex(clean, "}") if start == -1 || end == -1 || end <= start { return nil, fmt.Errorf("no json object found in: %s", clean) } obj := clean[start : end+1] if err := json.Unmarshal([]byte(obj), &out); err != nil { return nil, err } return &out, nil } // 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 { 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 } // 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 (st *SchedulePlanState) clearPreviousPreviewContext() { 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 { 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 } // 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 }