Files
smartmate/backend/agent/scheduleplan/nodes.go
Losita f3f9902e93 Version: 0.7.1.dev.260321
feat(agent):  重构智能排程分流与双通道交付,补齐周级预算并接入连续微调复用

- 🔀 通用路由升级为 action 分流(chat/quick_note_create/task_query/schedule_plan),路由失败直接返回内部错误,不再回落聊天
- 🧭 智能排程链路重构:统一图编排与节点职责,完善日级/周级调优协作与提示词约束
- 📊 周级预算改为“有效周保底 + 负载加权分配”,避免有效周零预算并提升资源利用率
- ⚙️ 日级并发优化细化:按天拆分 DayGroup 并发执行,低收益天(suggested<=2)跳过,单天失败仅回退该天结果并继续全局
- 🧵 周级并发优化细化:按周并发 worker 执行,单周“单步动作”循环(每轮仅 1 个 Move/Swap 或 done),失败周保留原方案不影响其它周
- 🛰️ 新增排程预览双通道:聊天主链路输出终审文本,结构化 candidate_plans 通过 /api/v1/agent/schedule-preview 拉取
- 🗃️ 增补 Redis 预览缓存读写与清理逻辑,新增对应 API、路由、模型与错误码支持
- ♻️ 接入连续对话微调复用:命中同会话历史预览时复用上轮 HybridEntries,避免每轮重跑粗排
- 🛡️ 增加复用保护:仅当本轮与上轮 task_class_ids 集合一致才复用;不一致回退全量粗排
- 🧰 扩展预览缓存字段(task_class_ids/hybrid_entries/allocated_items),支撑微调承接链路
- 🗺️ 更新 README 5.4 Mermaid(总分流图 + 智能排程流转图)并补充决策文档

- ⚠️ 新增“连续微调复用”链路我尚未完成测试,且文档状态目前较为混乱,待连续对话微调功能真正测试完成后再统一更新
2026-03-21 22:08:35 +08:00

684 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
}
// 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")
}
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
}
previousPlan := extractPreviousPlanFromHistory(chatHistory)
if previousPlan != "" {
st.PreviousPlanJSON = previousPlan
st.IsAdjustment = true
}
// 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"
}
// 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。", len(st.TaskClassIDs)),
)
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 &&
len(st.PreviousHybridEntries) > 0 &&
sameTaskClassSet(taskClassIDs, st.PreviousTaskClassIDs)
if canReusePreviousPlan {
emitStage("schedule_plan.rough_build.reuse_previous", "检测到连续对话微调,复用上一版排程作为优化起点。")
st.HybridEntries = deepCopyEntries(st.PreviousHybridEntries)
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
}
// 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
}