diff --git a/backend/agent/scheduleplan/graph.go b/backend/agent/scheduleplan/graph.go index 5f4010d..ed8460f 100644 --- a/backend/agent/scheduleplan/graph.go +++ b/backend/agent/scheduleplan/graph.go @@ -18,6 +18,8 @@ const ( schedulePlanGraphNodeExit = "schedule_plan_exit" // 图节点:按天拆分并注入上下文标签 schedulePlanGraphNodeDailySplit = "schedule_plan_daily_split" + // 图节点:小改动快速微调(用于 small scope) + schedulePlanGraphNodeQuickRefine = "schedule_plan_quick_refine" // 图节点:并发日内优化 schedulePlanGraphNodeDailyRefine = "schedule_plan_daily_refine" // 图节点:合并日内优化结果 @@ -120,6 +122,9 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) if err := graph.AddLambdaNode(schedulePlanGraphNodeDailySplit, compose.InvokableLambda(runner.dailySplitNode)); err != nil { return nil, err } + if err := graph.AddLambdaNode(schedulePlanGraphNodeQuickRefine, compose.InvokableLambda(runner.quickRefineNode)); err != nil { + return nil, err + } if err := graph.AddLambdaNode(schedulePlanGraphNodeDailyRefine, compose.InvokableLambda(runner.dailyRefineNode)); err != nil { return nil, err } @@ -157,6 +162,7 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) runner.nextAfterRoughBuild, map[string]bool{ schedulePlanGraphNodeDailySplit: true, + schedulePlanGraphNodeQuickRefine: true, schedulePlanGraphNodeWeeklyRefine: true, schedulePlanGraphNodeExit: true, }, @@ -164,7 +170,10 @@ func RunSchedulePlanGraph(ctx context.Context, input SchedulePlanGraphRunInput) return nil, err } - // 7. 固定边:dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END + // 7. 固定边:quickRefine -> weeklyRefine;dailySplit -> dailyRefine -> merge -> weeklyRefine -> finalCheck -> returnPreview -> END + if err := graph.AddEdge(schedulePlanGraphNodeQuickRefine, schedulePlanGraphNodeWeeklyRefine); err != nil { + return nil, err + } if err := graph.AddEdge(schedulePlanGraphNodeDailySplit, schedulePlanGraphNodeDailyRefine); err != nil { return nil, err } diff --git a/backend/agent/scheduleplan/nodes.go b/backend/agent/scheduleplan/nodes.go index 8f95692..9ba57dc 100644 --- a/backend/agent/scheduleplan/nodes.go +++ b/backend/agent/scheduleplan/nodes.go @@ -24,12 +24,16 @@ import ( // 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"` + 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”。 @@ -50,6 +54,10 @@ func runPlanNode( 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", "正在分析你的排程需求。") @@ -80,11 +88,13 @@ func runPlanNode( // 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. 组装模型提示词。 @@ -135,6 +145,25 @@ func runPlanNode( 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 先拼接已有值与模型输出; @@ -172,7 +201,13 @@ func runPlanNode( emitStage( "schedule_plan.plan.done", - fmt.Sprintf("已识别排程意图,任务类数量=%d。", len(st.TaskClassIDs)), + fmt.Sprintf( + "已识别排程意图,任务类数量=%d,微调=%t,力度=%s,重排=%t。", + len(st.TaskClassIDs), + st.IsAdjustment, + st.AdjustmentScope, + st.RestartRequested, + ), ) return st, nil } @@ -234,12 +269,16 @@ func runRoughBuildNode( // 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 = hybridEntriesToWeekSchedules(st.HybridEntries) + 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) @@ -601,6 +640,51 @@ func normalizeTaskClassIDs(ids []int) []int { 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 是否表示同一集合(忽略顺序,忽略重复)。 // // 语义: diff --git a/backend/agent/scheduleplan/prompt.go b/backend/agent/scheduleplan/prompt.go index fbdde42..0a3d84b 100644 --- a/backend/agent/scheduleplan/prompt.go +++ b/backend/agent/scheduleplan/prompt.go @@ -11,7 +11,8 @@ const ( // 输出约束: // 1. 必须只输出 JSON,禁止附加解释文本; // 2. task_class_ids 是主语义; - // 3. task_class_id 仅作为兼容字段保留,便于老链路平滑过渡。 + // 3. task_class_id 仅作为兼容字段保留,便于老链路平滑过渡; + // 4. 需要额外给出 restart + adjustment_scope,用于图分流。 SchedulePlanIntentPrompt = `你是 SmartFlow 的排程意图分析器。 请根据用户输入,提取排程意图与约束条件。 @@ -26,6 +27,14 @@ const ( - 兼容键:任务名称(例如 "高数复习") - 值只能是: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,不要解释。 @@ -36,7 +45,11 @@ const ( "task_class_ids": [12, 13], "task_class_id": 12, "strategy": "steady", - "task_tags": {"12":"High-Logic","英语阅读":"Memory"} + "task_tags": {"12":"High-Logic","英语阅读":"Memory"}, + "restart": false, + "adjustment_scope": "medium", + "reason": "本次只调整局部时段", + "confidence": 0.86 }` // SchedulePlanDailyReactPrompt 用于 daily_refine 节点。 diff --git a/backend/agent/scheduleplan/quick_refine.go b/backend/agent/scheduleplan/quick_refine.go new file mode 100644 index 0000000..8971dea --- /dev/null +++ b/backend/agent/scheduleplan/quick_refine.go @@ -0,0 +1,77 @@ +package scheduleplan + +import ( + "context" + "fmt" +) + +// 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 +} diff --git a/backend/agent/scheduleplan/runner.go b/backend/agent/scheduleplan/runner.go index e2f99ca..e23932a 100644 --- a/backend/agent/scheduleplan/runner.go +++ b/backend/agent/scheduleplan/runner.go @@ -67,6 +67,10 @@ func (r *schedulePlanRunner) dailySplitNode(ctx context.Context, st *SchedulePla return runDailySplitNode(ctx, st, r.emitStage) } +func (r *schedulePlanRunner) quickRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { + return runQuickRefineNode(ctx, st, r.emitStage) +} + func (r *schedulePlanRunner) dailyRefineNode(ctx context.Context, st *SchedulePlanState) (*SchedulePlanState, error) { return runDailyRefineNode(ctx, st, r.chatModel, r.dailyRefineConcurrency, r.emitStage) } @@ -107,6 +111,16 @@ func (r *schedulePlanRunner) nextAfterRoughBuild(_ context.Context, st *Schedule if st == nil || len(st.HybridEntries) == 0 { return schedulePlanGraphNodeExit, nil } + + // 1. 连续微调且判定为 small:先走快速微调节点,收缩预算后再进 weekly。 + if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeSmall { + return schedulePlanGraphNodeQuickRefine, nil + } + // 2. 连续微调且判定为 medium:直接走 weekly,跳过 daily。 + if st.IsAdjustment && st.AdjustmentScope == schedulePlanAdjustmentScopeMedium { + return schedulePlanGraphNodeWeeklyRefine, nil + } + // 3. large 或非微调:保持原有逻辑,多任务类走 daily,单任务类直达 weekly。 if len(st.TaskClassIDs) >= 2 { return schedulePlanGraphNodeDailySplit, nil } diff --git a/backend/agent/scheduleplan/state.go b/backend/agent/scheduleplan/state.go index 2832bd2..07acd8d 100644 --- a/backend/agent/scheduleplan/state.go +++ b/backend/agent/scheduleplan/state.go @@ -1,6 +1,7 @@ package scheduleplan import ( + "strings" "time" "github.com/LoveLosita/smartflow/backend/model" @@ -38,6 +39,16 @@ const ( // 1. 周级输入规模通常比单天更大,默认并发度不宜过高,避免触发模型侧限流; // 2. 可在运行时按请求状态覆盖。 schedulePlanDefaultWeeklyRefineConcurrency = 2 + + // schedulePlanAdjustmentScopeSmall 表示“小改动微调”。 + // 语义:优先走快速路径,只做轻量周级调整。 + schedulePlanAdjustmentScopeSmall = "small" + // schedulePlanAdjustmentScopeMedium 表示“中等改动微调”。 + // 语义:跳过日内拆分,直接进入周级配平。 + schedulePlanAdjustmentScopeMedium = "medium" + // schedulePlanAdjustmentScopeLarge 表示“大改动重排”。 + // 语义:必要时重新走全量路径(日内并发 + 周级配平)。 + schedulePlanAdjustmentScopeLarge = "large" ) // DayGroup 是“按天拆分后”的最小优化单元。 @@ -168,6 +179,23 @@ type SchedulePlanState struct { PreviousPlanJSON string // IsAdjustment 标记本次是否为微调请求(而非全新排程)。 IsAdjustment bool + // RestartRequested 标记本轮是否要求“放弃历史快照并重新排程”。 + // + // 语义: + // 1. true:强制清空 Previous* 并走全新构建; + // 2. false:允许按同会话历史快照做增量微调。 + RestartRequested bool + // AdjustmentScope 表示本轮改动力度分级(small/medium/large)。 + // + // 分流语义: + // 1. small:走快速微调节点,再进入周级优化; + // 2. medium:跳过 daily,直接周级优化; + // 3. large:优先走全量路径(多任务类时会经过 daily 并发)。 + AdjustmentScope string + // AdjustmentReason 是模型给出的力度判定理由,用于日志排障与 review。 + AdjustmentReason string + // AdjustmentConfidence 是模型给出的力度判定置信度(0-1)。 + AdjustmentConfidence float64 // HasPreviousPreview 标记是否命中“同会话上一次排程预览快照”。 // // 语义: @@ -192,6 +220,12 @@ type SchedulePlanState struct { // 1. 保持 final_check 的数量核对口径稳定; // 2. return_preview 阶段可继续回填 embedded_time。 PreviousAllocatedItems []model.TaskClassItem + // PreviousCandidatePlans 是上一版预览保存的周视图结构化结果。 + // + // 用途: + // 1. 连续微调时可直接复用,避免重复转换; + // 2. 兜底展示层(即使本轮未走全量粗排,仍可给前端稳定结构)。 + PreviousCandidatePlans []model.UserWeekSchedule // ── 最终输出 ── @@ -215,6 +249,7 @@ func NewSchedulePlanState(traceID string, userID int, conversationID string) *Sc TaskTagHintsByName: make(map[string]string), DailyRefineConcurrency: schedulePlanDefaultDailyRefineConcurrency, WeeklyRefineConcurrency: schedulePlanDefaultWeeklyRefineConcurrency, + AdjustmentScope: schedulePlanAdjustmentScopeLarge, ReactMaxRound: 2, WeeklyAdjustBudget: schedulePlanDefaultWeeklyAdjustBudget, WeeklyTotalBudget: schedulePlanDefaultWeeklyTotalBudget, @@ -234,3 +269,19 @@ func schedulePlanLocation() *time.Location { func schedulePlanNowToMinute() time.Time { return time.Now().In(schedulePlanLocation()).Truncate(time.Minute) } + +// normalizeAdjustmentScope 归一化排程微调力度字段。 +// +// 兜底策略: +// 1. 只接受 small/medium/large; +// 2. 任何未知值都回退为 large,保证不会误走“过轻”路径。 +func normalizeAdjustmentScope(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case schedulePlanAdjustmentScopeSmall: + return schedulePlanAdjustmentScopeSmall + case schedulePlanAdjustmentScopeMedium: + return schedulePlanAdjustmentScopeMedium + default: + return schedulePlanAdjustmentScopeLarge + } +} diff --git a/backend/cmd/start.go b/backend/cmd/start.go index c283215..194f8a5 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -98,7 +98,7 @@ func Start() { courseService := service.NewCourseService(courseRepo, scheduleRepo) taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager) scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo) - agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, agentCacheRepo, eventBus, scheduleService) + agentService := service.NewAgentServiceWithSchedule(aiHub, agentRepo, taskRepo, cacheRepo, agentCacheRepo, eventBus, scheduleService) // API 层初始化。 userApi := api.NewUserHandler(userService) diff --git a/backend/dao/agent-cache.go b/backend/dao/agent-cache.go index 6fe5667..aad6d18 100644 --- a/backend/dao/agent-cache.go +++ b/backend/dao/agent-cache.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - "github.com/LoveLosita/smartflow/backend/model" "github.com/cloudwego/eino/schema" "github.com/go-redis/redis/v8" ) @@ -41,10 +40,6 @@ func (m *AgentCache) historyWindowKey(sessionID string) string { return fmt.Sprintf("smartflow:history_window:%s", sessionID) } -func (m *AgentCache) schedulePreviewKey(userID int, sessionID string) string { - return fmt.Sprintf("smartflow:schedule_preview:u:%d:c:%s", userID, sessionID) -} - func (m *AgentCache) normalizeWindowSize(size int) int { if size < minHistoryWindowSize { return minHistoryWindowSize @@ -193,55 +188,3 @@ func (m *AgentCache) DeleteConversationStatus(ctx context.Context, sessionID str key := fmt.Sprintf("smartflow:conversation_status:%s", sessionID) return m.client.Del(ctx, key).Err() } - -// SetSchedulePlanPreview 写入“排程预览”缓存。 -// -// 步骤化说明: -// 1. 先把结构化预览序列化成 JSON,避免缓存层结构漂移。 -// 2. 再按 user_id + conversation_id 写入,确保用户间数据隔离。 -// 3. 最后带 TTL 写入,保证预览是短期临时态而非长期状态。 -// -// 失败处理: -// 1. preview 为空时直接返回错误,避免写入无意义空值。 -// 2. 序列化失败或 Redis 写入失败都返回 error,由上层决定是否降级。 -func (m *AgentCache) SetSchedulePlanPreview(ctx context.Context, userID int, sessionID string, preview *model.SchedulePlanPreviewCache) error { - if preview == nil { - return fmt.Errorf("schedule preview is nil") - } - data, err := json.Marshal(preview) - if err != nil { - return fmt.Errorf("marshal schedule preview failed: %w", err) - } - return m.client.Set(ctx, m.schedulePreviewKey(userID, sessionID), data, m.expiration).Err() -} - -// GetSchedulePlanPreview 读取“排程预览”缓存。 -// -// 语义约定: -// 1. 未命中返回 (nil, nil),上层可区分“未生成”与“已过期”。 -// 2. 反序列化失败返回 error,避免把脏缓存当成正常结果。 -// 3. 不做 DB 回源,预览缓存失效后由业务侧重新生成。 -func (m *AgentCache) GetSchedulePlanPreview(ctx context.Context, userID int, sessionID string) (*model.SchedulePlanPreviewCache, error) { - raw, err := m.client.Get(ctx, m.schedulePreviewKey(userID, sessionID)).Result() - if err == redis.Nil { - return nil, nil - } - if err != nil { - return nil, err - } - - var preview model.SchedulePlanPreviewCache - if err = json.Unmarshal([]byte(raw), &preview); err != nil { - return nil, fmt.Errorf("unmarshal schedule preview failed: %w", err) - } - return &preview, nil -} - -// DeleteSchedulePlanPreview 删除“排程预览”缓存。 -// -// 说明: -// 1. 删除是幂等操作,key 不存在也视为成功。 -// 2. 用于新一轮排程前清理旧快照,避免前端读到过期结果。 -func (m *AgentCache) DeleteSchedulePlanPreview(ctx context.Context, userID int, sessionID string) error { - return m.client.Del(ctx, m.schedulePreviewKey(userID, sessionID)).Err() -} diff --git a/backend/dao/agent_schedule_state.go b/backend/dao/agent_schedule_state.go new file mode 100644 index 0000000..bc040d6 --- /dev/null +++ b/backend/dao/agent_schedule_state.go @@ -0,0 +1,252 @@ +package dao + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UpsertScheduleStateSnapshot 以“user_id + conversation_id”维度写入/覆盖排程状态快照。 +// +// 职责边界: +// 1. 负责把强类型快照序列化并持久化到 agent_schedule_states; +// 2. 负责 upsert 冲突更新(同会话覆盖),并自动 revision+1; +// 3. 不负责 Redis 缓存读写,不负责业务分流,不负责正式日程落库。 +// +// 步骤化说明: +// 1. 先做参数与主键语义校验,避免把脏快照写入数据库; +// 2. 再把切片字段统一序列化为 JSON,保证表内口径稳定; +// 3. 最后执行 OnConflict upsert: +// 3.1 新记录直接插入; +// 3.2 已存在记录则覆盖业务字段,并把 revision 自增; +// 3.3 任一阶段失败都返回 error,由上层决定是否降级。 +func (a *AgentDAO) UpsertScheduleStateSnapshot(ctx context.Context, snapshot *model.SchedulePlanStateSnapshot) error { + if a == nil || a.db == nil { + return errors.New("agent dao is not initialized") + } + if snapshot == nil { + return errors.New("schedule state snapshot is nil") + } + if snapshot.UserID <= 0 { + return fmt.Errorf("invalid snapshot user_id: %d", snapshot.UserID) + } + conversationID := strings.TrimSpace(snapshot.ConversationID) + if conversationID == "" { + return errors.New("schedule state snapshot conversation_id is empty") + } + + taskClassIDsJSON, err := marshalJSONOrDefault(snapshot.TaskClassIDs, "[]") + if err != nil { + return fmt.Errorf("marshal task_class_ids failed: %w", err) + } + constraintsJSON, err := marshalJSONOrDefault(snapshot.Constraints, "[]") + if err != nil { + return fmt.Errorf("marshal constraints failed: %w", err) + } + hybridEntriesJSON, err := marshalJSONOrDefault(snapshot.HybridEntries, "[]") + if err != nil { + return fmt.Errorf("marshal hybrid_entries failed: %w", err) + } + allocatedItemsJSON, err := marshalJSONOrDefault(snapshot.AllocatedItems, "[]") + if err != nil { + return fmt.Errorf("marshal allocated_items failed: %w", err) + } + candidatePlansJSON, err := marshalJSONOrDefault(snapshot.CandidatePlans, "[]") + if err != nil { + return fmt.Errorf("marshal candidate_plans failed: %w", err) + } + + stateVersion := snapshot.StateVersion + if stateVersion <= 0 { + stateVersion = model.SchedulePlanStateVersionV1 + } + revision := snapshot.Revision + if revision <= 0 { + revision = 1 + } + + row := model.AgentScheduleState{ + UserID: snapshot.UserID, + ConversationID: conversationID, + Revision: revision, + StateVersion: stateVersion, + TaskClassIDsJSON: taskClassIDsJSON, + ConstraintsJSON: constraintsJSON, + HybridEntriesJSON: hybridEntriesJSON, + AllocatedItemsJSON: allocatedItemsJSON, + CandidatePlansJSON: candidatePlansJSON, + UserIntent: strings.TrimSpace(snapshot.UserIntent), + Strategy: normalizeStrategy(snapshot.Strategy), + AdjustmentScope: normalizeAdjustmentScope(snapshot.AdjustmentScope), + RestartRequested: snapshot.RestartRequested, + FinalSummary: strings.TrimSpace(snapshot.FinalSummary), + Completed: snapshot.Completed, + TraceID: strings.TrimSpace(snapshot.TraceID), + } + + now := time.Now() + return a.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_id"}, + {Name: "conversation_id"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "revision": gorm.Expr("revision + 1"), + "state_version": row.StateVersion, + "task_class_ids": row.TaskClassIDsJSON, + "constraints": row.ConstraintsJSON, + "hybrid_entries": row.HybridEntriesJSON, + "allocated_items": row.AllocatedItemsJSON, + "candidate_plans": row.CandidatePlansJSON, + "user_intent": row.UserIntent, + "strategy": row.Strategy, + "adjustment_scope": row.AdjustmentScope, + "restart_requested": row.RestartRequested, + "final_summary": row.FinalSummary, + "completed": row.Completed, + "trace_id": row.TraceID, + "updated_at": now, + }), + }).Create(&row).Error +} + +// GetScheduleStateSnapshot 读取指定会话的排程状态快照。 +// +// 职责边界: +// 1. 负责按 user_id + conversation_id 查询快照; +// 2. 负责把数据库 JSON 字段反序列化回强类型结构; +// 3. 不负责回填 Redis,不负责业务分流判定。 +// +// 返回语义: +// 1. 命中:返回 snapshot, nil; +// 2. 未命中:返回 nil, nil(上层可继续走其他兜底); +// 3. 反序列化失败:返回 error(说明库内数据不合法,需要排障)。 +func (a *AgentDAO) GetScheduleStateSnapshot(ctx context.Context, userID int, conversationID string) (*model.SchedulePlanStateSnapshot, error) { + if a == nil || a.db == nil { + return nil, errors.New("agent dao is not initialized") + } + if userID <= 0 { + return nil, fmt.Errorf("invalid user_id: %d", userID) + } + normalizedConversationID := strings.TrimSpace(conversationID) + if normalizedConversationID == "" { + return nil, errors.New("conversation_id is empty") + } + + var row model.AgentScheduleState + err := a.db.WithContext(ctx). + Where("user_id = ? AND conversation_id = ?", userID, normalizedConversationID). + First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + taskClassIDs := make([]int, 0) + if err = unmarshalJSONOrDefault(row.TaskClassIDsJSON, &taskClassIDs, []int{}); err != nil { + return nil, fmt.Errorf("unmarshal task_class_ids failed: %w", err) + } + constraints := make([]string, 0) + if err = unmarshalJSONOrDefault(row.ConstraintsJSON, &constraints, []string{}); err != nil { + return nil, fmt.Errorf("unmarshal constraints failed: %w", err) + } + hybridEntries := make([]model.HybridScheduleEntry, 0) + if err = unmarshalJSONOrDefault(row.HybridEntriesJSON, &hybridEntries, []model.HybridScheduleEntry{}); err != nil { + return nil, fmt.Errorf("unmarshal hybrid_entries failed: %w", err) + } + allocatedItems := make([]model.TaskClassItem, 0) + if err = unmarshalJSONOrDefault(row.AllocatedItemsJSON, &allocatedItems, []model.TaskClassItem{}); err != nil { + return nil, fmt.Errorf("unmarshal allocated_items failed: %w", err) + } + candidatePlans := make([]model.UserWeekSchedule, 0) + if err = unmarshalJSONOrDefault(row.CandidatePlansJSON, &candidatePlans, []model.UserWeekSchedule{}); err != nil { + return nil, fmt.Errorf("unmarshal candidate_plans failed: %w", err) + } + + return &model.SchedulePlanStateSnapshot{ + UserID: row.UserID, + ConversationID: row.ConversationID, + Revision: row.Revision, + StateVersion: row.StateVersion, + TaskClassIDs: taskClassIDs, + Constraints: constraints, + HybridEntries: hybridEntries, + AllocatedItems: allocatedItems, + CandidatePlans: candidatePlans, + UserIntent: row.UserIntent, + Strategy: normalizeStrategy(row.Strategy), + AdjustmentScope: normalizeAdjustmentScope(row.AdjustmentScope), + RestartRequested: row.RestartRequested, + FinalSummary: row.FinalSummary, + Completed: row.Completed, + TraceID: row.TraceID, + UpdatedAt: row.UpdatedAt, + }, nil +} + +// marshalJSONOrDefault 统一处理“结构体 -> JSON 字符串”序列化。 +// +// 设计目的: +// 1. 避免每个字段手写重复的 marshal 判空逻辑; +// 2. nil 场景统一写成默认 JSON(例如 [])以保持数据库口径稳定; +// 3. 序列化失败直接上抛,防止写入半成品快照。 +func marshalJSONOrDefault(v any, defaultJSON string) (string, error) { + if v == nil { + return defaultJSON, nil + } + raw, err := json.Marshal(v) + if err != nil { + return "", err + } + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return defaultJSON, nil + } + return text, nil +} + +// unmarshalJSONOrDefault 统一处理“JSON 字符串 -> 结构体”反序列化。 +// +// 设计目的: +// 1. 数据为空、null 时回落到默认值,避免上层到处判空; +// 2. 保留错误上抛,便于定位历史脏数据; +// 3. 保障读取到的快照字段始终有确定值语义。 +func unmarshalJSONOrDefault[T any](raw string, target *T, defaultValue T) error { + clean := strings.TrimSpace(raw) + if clean == "" || clean == "null" { + *target = defaultValue + return nil + } + return json.Unmarshal([]byte(clean), target) +} + +// normalizeStrategy 归一化快照中的 strategy 字段。 +func normalizeStrategy(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "rapid": + return "rapid" + default: + return "steady" + } +} + +// normalizeAdjustmentScope 归一化快照中的微调力度字段。 +func normalizeAdjustmentScope(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "small": + return "small" + case "medium": + return "medium" + default: + return "large" + } +} diff --git a/backend/dao/cache.go b/backend/dao/cache.go index 91b834d..0d9e6f1 100644 --- a/backend/dao/cache.go +++ b/backend/dao/cache.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/LoveLosita/smartflow/backend/model" @@ -30,6 +31,10 @@ func NewCacheDAO(client *redis.Client) *CacheDAO { return &CacheDAO{client: client} } +func (d *CacheDAO) schedulePreviewKey(userID int, conversationID string) string { + return fmt.Sprintf("smartflow:schedule_preview:u:%d:c:%s", userID, conversationID) +} + // SetBlacklist 鎶?Token 鎵旇繘榛戝悕鍗? func (d *CacheDAO) SetBlacklist(jti string, expiration time.Duration) error { return d.client.Set(context.Background(), "blacklist:"+jti, "1", expiration).Err() @@ -353,3 +358,88 @@ func (d *CacheDAO) SetUserTokenBlocked(ctx context.Context, userID int, ttl time func (d *CacheDAO) DeleteUserTokenBlocked(ctx context.Context, userID int) error { return d.client.Del(ctx, userTokenBlockedKey(userID)).Err() } + +// SetSchedulePlanPreviewToCache 写入“排程预览”缓存。 +// +// 职责边界: +// 1. 负责按 user_id + conversation_id 写入结构化预览快照; +// 2. 负责 preview 入库前的基础参数校验,避免无效 key; +// 3. 不负责 DB 回源,不负责业务重试策略。 +// +// 步骤化说明: +// 1. 先校验 user_id / conversation_id / preview,防止脏写; +// 2. 再序列化 preview 为 JSON,保证缓存结构稳定; +// 3. 最后按固定 TTL 写入 Redis,超时后自动失效。 +func (d *CacheDAO) SetSchedulePlanPreviewToCache(ctx context.Context, userID int, conversationID string, preview *model.SchedulePlanPreviewCache) error { + if d == nil || d.client == nil { + return errors.New("cache dao is not initialized") + } + if userID <= 0 { + return fmt.Errorf("invalid user_id: %d", userID) + } + normalizedConversationID := strings.TrimSpace(conversationID) + if normalizedConversationID == "" { + return errors.New("conversation_id is empty") + } + if preview == nil { + return errors.New("schedule preview is nil") + } + + data, err := json.Marshal(preview) + if err != nil { + return fmt.Errorf("marshal schedule preview failed: %w", err) + } + return d.client.Set(ctx, d.schedulePreviewKey(userID, normalizedConversationID), data, 1*time.Hour).Err() +} + +// GetSchedulePlanPreviewFromCache 读取“排程预览”缓存。 +// +// 输入输出语义: +// 1. 命中时返回 (*SchedulePlanPreviewCache, nil); +// 2. 未命中时返回 (nil, nil); +// 3. Redis 异常或反序列化失败时返回 error。 +func (d *CacheDAO) GetSchedulePlanPreviewFromCache(ctx context.Context, userID int, conversationID string) (*model.SchedulePlanPreviewCache, error) { + if d == nil || d.client == nil { + return nil, errors.New("cache dao is not initialized") + } + if userID <= 0 { + return nil, fmt.Errorf("invalid user_id: %d", userID) + } + normalizedConversationID := strings.TrimSpace(conversationID) + if normalizedConversationID == "" { + return nil, errors.New("conversation_id is empty") + } + + raw, err := d.client.Get(ctx, d.schedulePreviewKey(userID, normalizedConversationID)).Result() + if err == redis.Nil { + return nil, nil + } + if err != nil { + return nil, err + } + + var preview model.SchedulePlanPreviewCache + if err = json.Unmarshal([]byte(raw), &preview); err != nil { + return nil, fmt.Errorf("unmarshal schedule preview failed: %w", err) + } + return &preview, nil +} + +// DeleteSchedulePlanPreviewFromCache 删除“排程预览”缓存。 +// +// 说明: +// 1. 删除操作是幂等的,key 不存在也视为成功; +// 2. 该方法用于新排程前清旧预览,或状态快照更新后触发失效。 +func (d *CacheDAO) DeleteSchedulePlanPreviewFromCache(ctx context.Context, userID int, conversationID string) error { + if d == nil || d.client == nil { + return errors.New("cache dao is not initialized") + } + if userID <= 0 { + return fmt.Errorf("invalid user_id: %d", userID) + } + normalizedConversationID := strings.TrimSpace(conversationID) + if normalizedConversationID == "" { + return errors.New("conversation_id is empty") + } + return d.client.Del(ctx, d.schedulePreviewKey(userID, normalizedConversationID)).Err() +} diff --git a/backend/inits/mysql.go b/backend/inits/mysql.go index 71ca055..42e1dc8 100644 --- a/backend/inits/mysql.go +++ b/backend/inits/mysql.go @@ -21,6 +21,7 @@ func autoMigrateModels(db *gorm.DB) error { &model.ScheduleEvent{}, &model.Schedule{}, &model.AgentOutboxMessage{}, + &model.AgentScheduleState{}, } for _, m := range models { diff --git a/backend/logic/smart_planning.go b/backend/logic/smart_planning.go index 47008bd..c051cf4 100644 --- a/backend/logic/smart_planning.go +++ b/backend/logic/smart_planning.go @@ -113,9 +113,6 @@ func (g *grid) FindNextAvailable(currW, currD, currS int) (int, int, int) { if w == currW && d == currD && s < currS { continue } - if w == g.endWeek && d == g.endDay { - break - } // 🚀 守住结束节 if dayData[s].Status == Free || dayData[s].Status == Filler { return w, d, s @@ -439,13 +436,31 @@ type slotCoord struct { w, d, s int } -// getAllAvailable 获取窗口内所有可用的原子节次坐标(逻辑一维化) +// planningSlotCandidate 表示一次“可落位任务块”的候选结果。 +// +// 职责边界: +// 1. 负责把“游标位置”映射成真正可落地的周/天/节次区间; +// 2. 不负责写入 grid,占位仍由 computeAllocation 统一执行; +// 3. 通过 coordIndex 告诉上层“本次是从哪个逻辑切片位置开始命中的”,便于继续推进游标。 +type planningSlotCandidate struct { + coordIndex int + week int + dayOfWeek int + sectionFrom int + sectionTo int +} + +// getAllAvailable 获取窗口内所有可用的原子节次坐标(逻辑一维化)。 +// +// 设计说明: +// 1. 这里返回的是“快照坐标”,后续任务落位后,快照中的部分坐标可能失效; +// 2. 因此 computeAllocation 在真正落位前会再次检查 grid 当前状态,避免覆盖占位。 func (g *grid) getAllAvailable() []slotCoord { var coords []slotCoord for w := g.startWeek; w <= g.endWeek; w++ { dayMap, hasData := g.data[w] for d := 1; d <= 7; d++ { - // 边界裁剪逻辑 + // 1. 头尾边界裁剪:只遍历任务类有效日期窗口。 if w == g.startWeek && d < g.startDay { continue } @@ -458,10 +473,10 @@ func (g *grid) getAllAvailable() []slotCoord { dayData = dayMap[d] } + // 2. 仅记录可用格子(Free/Filler)。 for s := 1; s <= 12; s++ { - // 顺着你的逻辑,不限开始节次,但需注意状态判定 if dayData[s].Status == Free || dayData[s].Status == Filler { - coords = append(coords, slotCoord{w, d, s}) + coords = append(coords, slotCoord{w: w, d: d, s: s}) } } } @@ -469,28 +484,137 @@ func (g *grid) getAllAvailable() []slotCoord { return coords } +// findNextCandidateFromCursor 从当前 cursor 起向后寻找“可真正落位”的候选块。 +// +// 职责边界: +// 1. 负责“挑选起点”:从逻辑切片 coords 中向后扫描,直到命中可放置位置; +// 2. 不负责“真正占位”:这里只做判断,不修改 grid 状态; +// 3. 输入输出语义: +// - startCursor:当前逻辑游标(已包含 steady 策略的间隔效果); +// - found=false:表示从该游标到窗口末尾都无法再放置任务块。 +// +// 关键约束: +// 1. 普通空位(Free)必须满足“连续 2 节都可用”才允许落位; +// 2. 可嵌入课程(Filler)沿用“整块嵌入”语义:命中课程任意节次,都回溯到课程块起点并整块占用; +// 3. 若某个坐标在前序迭代中已占用(coords 为快照可能过期),直接跳过继续扫描。 +func (g *grid) findNextCandidateFromCursor(coords []slotCoord, startCursor int) (candidate planningSlotCandidate, found bool) { + for idx := startCursor; idx < len(coords); idx++ { + loc := coords[idx] + node := g.getNode(loc.w, loc.d, loc.s) + + // 1. 快照过期校验: + // 1.1 前序任务落位后,该坐标可能已变成 Occupied; + // 1.2 若不二次校验,会出现覆盖已占位节次的风险。 + if node.Status != Free && node.Status != Filler { + continue + } + + // 2. Filler 处理: + // 2.1 先识别课程块边界; + // 2.2 再在课程块内部寻找“奇数起点的双节对齐位”(1-2/3-4/...); + // 2.3 找不到合法双节位则跳过该课程块,不允许退化成单节或偶数起点跨对齐块。 + if node.Status == Filler { + blockFrom := loc.s + currID := node.EventID + + // 2.1 向左回溯到同一 EventID 的起点。 + for checkS := loc.s - 1; checkS >= 1; checkS-- { + prev := g.getNode(loc.w, loc.d, checkS) + if prev.Status == Filler && prev.EventID == currID { + blockFrom = checkS + continue + } + break + } + + // 2.2 向右扩展到同一 EventID 的终点。 + blockTo := blockFrom + for checkS := blockFrom + 1; checkS <= 12; checkS++ { + next := g.getNode(loc.w, loc.d, checkS) + if next.Status == Filler && next.EventID == currID { + blockTo = checkS + continue + } + break + } + + // 2.3 在课程块中按“双节对齐位”查找合法起点(必须为奇数节)。 + pairFrom := blockFrom + if pairFrom%2 == 0 { + pairFrom++ + } + for ; pairFrom+1 <= blockTo; pairFrom += 2 { + // 虽然理论上 Filler 都可用,这里仍做显式校验,防止后续规则扩展导致误判。 + if g.isAvailable(loc.w, loc.d, pairFrom) && g.isAvailable(loc.w, loc.d, pairFrom+1) { + return planningSlotCandidate{ + coordIndex: idx, + week: loc.w, + dayOfWeek: loc.d, + sectionFrom: pairFrom, + sectionTo: pairFrom + 1, + }, true + } + } + continue + } + + // 3. Free 处理:必须严格满足“奇数起点双节对齐位”。 + // 3.1 起点必须是奇数节(1/3/5/7/9/11); + // 3.2 且后一节可用;不允许偶数起点(如 8-9)跨对齐块。 + if loc.s%2 == 0 { + continue + } + if loc.s >= 12 || !g.isAvailable(loc.w, loc.d, loc.s+1) { + continue + } + + return planningSlotCandidate{ + coordIndex: idx, + week: loc.w, + dayOfWeek: loc.d, + sectionFrom: loc.s, + sectionTo: loc.s + 1, + }, true + } + + return planningSlotCandidate{}, false +} + +// computeAllocation 根据当前时间格与策略,为每个任务块计算建议落位时间。 +// +// 职责边界: +// 1. 负责“粗排落位”与“内存占位状态更新”; +// 2. 不负责持久化写库(由 service/dao 层负责); +// 3. 不负责最终展示结构转换(由 conv 层负责)。 +// +// 失败语义: +// 1. 返回 TimeNotEnoughForAutoScheduling 表示“时间片总量或连续性不足”; +// 2. 返回 nil error 表示所有 items 都已成功回填 EmbeddedTime。 func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([]model.TaskClassItem, error) { if len(items) == 0 { return items, nil } - // 1. 预处理:提取所有可用坑位 + // 1. 预处理可用坐标快照,并做容量下限校验(每个任务默认至少 2 节)。 coords := g.getAllAvailable() totalAvailable := len(coords) - totalRequired := len(items) * 2 // 基础需求:每个任务 2 节 - + totalRequired := len(items) * 2 if totalAvailable < totalRequired { return nil, respond.TimeNotEnoughForAutoScheduling } - // 2. 计算精准步长 + // 2. 计算间隔策略: + // 2.1 rapid:gap=0,尽快塞满; + // 2.2 steady:按剩余可用位均匀留白。 gap := 0 if strategy == "steady" { gap = (totalAvailable - totalRequired) / (len(items) + 1) } - // 3. 线性映射分配 - // cursor 是我们在逻辑切片中的“指针” + // 3. 线性分配主循环: + // 3.1 cursor 是逻辑切片游标(不是物理节次指针); + // 3.2 每次成功落位后,按“命中索引 + 占用长度 + gap”推进; + // 3.3 若当前位置不满足约束(例如后继节被占),继续向后扫描,不降级为 1 节。 cursor := gap lastPlacedIndex := -1 @@ -499,64 +623,38 @@ func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([ break } - // 获取当前逻辑位置对应的物理坐标 - startLoc := coords[cursor] - w, d, s := startLoc.w, startLoc.d, startLoc.s - - // 4. 计算本次任务块的落点区间。 - // 4.1 默认按 2 节处理(普通空闲位优先遵循“每任务2节”的主策略); - // 4.2 命中 Filler(可嵌入课程)时,必须先回溯到同课程块起点,再计算完整连续跨度; - // 4.3 失败兜底:若普通空闲位后继不可用,只能退化为 1 节,避免越界或覆盖占用位。 - node := g.getNode(w, d, s) - sectionFrom := s - slotLen := 2 - if node.Status == Filler { - // 4.2.1 先向左回溯到“同一课程块”的起点。 - // 目的:修复“指针落在课程中间节次时被错误切成 1 节”的问题。 - // 例如课程占 9-10 节,若 cursor 命中 10 节,必须回溯到 9 节再整体计算。 - currID := node.EventID - for checkS := s - 1; checkS >= 1; checkS-- { - prev := g.getNode(w, d, checkS) - if prev.Status == Filler && prev.EventID == currID { - sectionFrom = checkS - continue - } - break - } - - // 4.2.2 再从起点向右扩展,拿到同一课程块的完整连续节次长度。 - sectionTo := sectionFrom - for checkS := sectionFrom + 1; checkS <= 12; checkS++ { - if next := g.getNode(w, d, checkS); next.Status == Filler && next.EventID == currID { - sectionTo = checkS - } else { - break - } - } - slotLen = sectionTo - sectionFrom + 1 - } else if s == 12 || !g.isAvailable(w, d, s+1) { - // 如果是 Free 区域,但下一节不可用,则被迫设为 1 节 - slotLen = 1 + // 4. 先找候选,不立即写入: + // 4.1 找不到候选时提前结束; + // 4.2 最终统一通过 lastPlacedIndex 判断是否完整排完。 + candidate, found := g.findNextCandidateFromCursor(coords, cursor) + if !found { + break } - // 回填时间 - endS := sectionFrom + slotLen - 1 + // 5. 回填任务块建议时间。 items[i].EmbeddedTime = &model.TargetTime{ - SectionFrom: sectionFrom, SectionTo: endS, - Week: w, DayOfWeek: d, + SectionFrom: candidate.sectionFrom, + SectionTo: candidate.sectionTo, + Week: candidate.week, + DayOfWeek: candidate.dayOfWeek, } - // 标记占用 (物理网格) - for sec := sectionFrom; sec <= endS; sec++ { - g.setNode(w, d, sec, slotNode{Status: Occupied}) + // 6. 写入内存占位状态: + // 6.1 这是后续候选判断的真实依据; + // 6.2 失败兜底:纯内存操作无外部 IO,不存在部分提交问题。 + for sec := candidate.sectionFrom; sec <= candidate.sectionTo; sec++ { + g.setNode(candidate.week, candidate.dayOfWeek, sec, slotNode{Status: Occupied}) } - // 🚀 核心进步:逻辑跳跃 - // 既然任务占用了 slotLen 节,我们在逻辑切片中也向后推 slotLen 个位置,再加 gap - cursor += slotLen + gap + // 7. 推进游标并记录成功位置。 + slotLen := candidate.sectionTo - candidate.sectionFrom + 1 + cursor = candidate.coordIndex + slotLen + gap lastPlacedIndex = i } + // 8. 完整性校验: + // 8.1 只要有任一任务未落位,就返回统一的“时间不足”错误; + // 8.2 避免出现“部分任务有时间、部分任务为空”的半成品结果。 if lastPlacedIndex < len(items)-1 { return nil, respond.TimeNotEnoughForAutoScheduling } diff --git a/backend/logic/smart_planning_test.go b/backend/logic/smart_planning_test.go new file mode 100644 index 0000000..35b363b --- /dev/null +++ b/backend/logic/smart_planning_test.go @@ -0,0 +1,154 @@ +package logic + +import ( + "testing" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" +) + +// newTestGrid 创建仅用于单测的最小 grid。 +// +// 职责边界: +// 1. 只负责初始化时间窗口与 data 容器; +// 2. 不负责填充节次状态(由各测试用例自行设置)。 +func newTestGrid(startWeek, startDay, endWeek, endDay int) *grid { + return &grid{ + data: make(map[int]map[int][13]slotNode), + startWeek: startWeek, + startDay: startDay, + endWeek: endWeek, + endDay: endDay, + } +} + +// setDayStatus 批量设置某一天 1~12 节的状态。 +func setDayStatus(g *grid, week, day int, status slotStatus) { + for s := 1; s <= 12; s++ { + g.setNode(week, day, s, slotNode{Status: status}) + } +} + +// setSectionStatus 设置单个节次状态。 +func setSectionStatus(g *grid, week, day, section int, status slotStatus) { + g.setNode(week, day, section, slotNode{Status: status}) +} + +// TestComputeAllocation_SkipIsolatedOneSlot 验证“孤立 1 节”不会被错误写成任务。 +// +// 用例意图: +// 1. 第一天只放一个孤立可用节次(10 节),后继 11 节被屏蔽; +// 2. 第二天提供一个合法的连续 2 节(1-2 节); +// 3. 期望算法跳过第一天孤立节次,把任务落到第二天 1-2 节。 +func TestComputeAllocation_SkipIsolatedOneSlot(t *testing.T) { + g := newTestGrid(1, 1, 1, 2) + + // 1. 先全部置为 Blocked,避免默认 Free 干扰本用例。 + setDayStatus(g, 1, 1, Blocked) + setDayStatus(g, 1, 2, Blocked) + + // 2. 构造“孤立 1 节 + 合法 2 节”场景。 + setSectionStatus(g, 1, 1, 10, Free) // 第一天仅 10 节可用,11/12 仍然 Blocked。 + setSectionStatus(g, 1, 2, 1, Free) + setSectionStatus(g, 1, 2, 2, Free) + + items := []model.TaskClassItem{{ID: 1}} + got, err := computeAllocation(g, items, "rapid") + if err != nil { + t.Fatalf("期望分配成功,实际报错: %v", err) + } + if len(got) != 1 || got[0].EmbeddedTime == nil { + t.Fatalf("期望回填 1 条 EmbeddedTime,实际: %+v", got) + } + + tt := got[0].EmbeddedTime + if tt.Week != 1 || tt.DayOfWeek != 2 || tt.SectionFrom != 1 || tt.SectionTo != 2 { + t.Fatalf("期望落位到 W1D2 1-2 节,实际: week=%d day=%d from=%d to=%d", + tt.Week, tt.DayOfWeek, tt.SectionFrom, tt.SectionTo) + } +} + +// TestComputeAllocation_RejectAllIsolatedSlots 验证“全是孤立 1 节”时应返回时间不足。 +// +// 用例意图: +// 1. 虽然总可用节次数量达到 2,但它们分散成两个孤立 1 节; +// 2. 业务要求普通任务默认必须 2 连续节,因此应整体失败而不是偷偷降级为 1 节。 +func TestComputeAllocation_RejectAllIsolatedSlots(t *testing.T) { + g := newTestGrid(1, 1, 1, 2) + + // 1. 先全部置为 Blocked。 + setDayStatus(g, 1, 1, Blocked) + setDayStatus(g, 1, 2, Blocked) + + // 2. 仅放两个彼此分离的孤立可用节次。 + setSectionStatus(g, 1, 1, 10, Free) + setSectionStatus(g, 1, 2, 10, Free) + + items := []model.TaskClassItem{{ID: 1}} + _, err := computeAllocation(g, items, "rapid") + if err == nil { + t.Fatalf("期望返回时间不足错误,实际为 nil") + } + if err.Error() != respond.TimeNotEnoughForAutoScheduling.Error() { + t.Fatalf("期望错误=%s,实际=%s", respond.TimeNotEnoughForAutoScheduling.Error(), err.Error()) + } +} + +// TestComputeAllocation_RejectEvenStartPair 验证偶数起点双节(如 8-9)不允许作为粗排结果。 +// +// 用例意图: +// 1. 构造一个看似连续的 8-9 空位; +// 2. 同时给出一个合法的 11-12 对齐空位; +// 3. 期望算法跳过 8-9,选择 11-12。 +func TestComputeAllocation_RejectEvenStartPair(t *testing.T) { + g := newTestGrid(1, 1, 1, 1) + + // 1. 全部先置为 Blocked,避免默认 Free 干扰判断。 + setDayStatus(g, 1, 1, Blocked) + + // 2. 构造“偶数起点双节 + 合法奇数起点双节”。 + setSectionStatus(g, 1, 1, 8, Free) + setSectionStatus(g, 1, 1, 9, Free) + setSectionStatus(g, 1, 1, 11, Free) + setSectionStatus(g, 1, 1, 12, Free) + + items := []model.TaskClassItem{{ID: 1}} + got, err := computeAllocation(g, items, "rapid") + if err != nil { + t.Fatalf("期望分配成功,实际报错: %v", err) + } + if got[0].EmbeddedTime == nil { + t.Fatalf("期望回填 EmbeddedTime,实际为 nil") + } + + tt := got[0].EmbeddedTime + if tt.SectionFrom != 11 || tt.SectionTo != 12 { + t.Fatalf("期望落位到 11-12,实际落位到 %d-%d", tt.SectionFrom, tt.SectionTo) + } +} + +// TestComputeAllocation_FillerNeedOddEvenPair 验证 Filler 课程块也必须满足奇数起点双节对齐。 +// +// 用例意图: +// 1. 仅提供一个 Filler 课程块 8-9(偶数起点); +// 2. 即使总可用节数为 2,也不能被当作合法落位; +// 3. 期望返回时间不足错误。 +func TestComputeAllocation_FillerNeedOddEvenPair(t *testing.T) { + g := newTestGrid(1, 1, 1, 1) + + // 1. 全部先置为 Blocked。 + setDayStatus(g, 1, 1, Blocked) + + // 2. 课程块 8-9 标记为 Filler,但其起点为偶数,不满足对齐规则。 + g.setNode(1, 1, 8, slotNode{Status: Filler, EventID: 1001}) + g.setNode(1, 1, 9, slotNode{Status: Filler, EventID: 1001}) + + items := []model.TaskClassItem{{ID: 1}} + _, err := computeAllocation(g, items, "rapid") + if err == nil { + t.Fatalf("期望返回时间不足错误,实际为 nil") + } + if err.Error() != respond.TimeNotEnoughForAutoScheduling.Error() { + t.Fatalf("期望错误=%s,实际=%s", respond.TimeNotEnoughForAutoScheduling.Error(), err.Error()) + } +} diff --git a/backend/middleware/cache_deleter.go b/backend/middleware/cache_deleter.go index 151ad6f..02384b6 100644 --- a/backend/middleware/cache_deleter.go +++ b/backend/middleware/cache_deleter.go @@ -4,6 +4,7 @@ import ( "context" "log" "reflect" + "strings" "github.com/LoveLosita/smartflow/backend/dao" "github.com/LoveLosita/smartflow/backend/model" @@ -62,6 +63,8 @@ func (p *GormCachePlugin) dispatchCacheLogic(modelObj interface{}, db *gorm.DB) p.invalidTaskClassCache(*m.UserID) case model.Task: p.invalidTaskCache(m.UserID) + case model.AgentScheduleState: + p.invalidSchedulePlanPreviewCache(m.UserID, m.ConversationID) case model.AgentOutboxMessage, model.ChatHistory, model.AgentChat, model.User: // 这些模型目前没有定义缓存逻辑,先不处理 default: @@ -104,3 +107,20 @@ func (p *GormCachePlugin) invalidTaskCache(userID int) { log.Printf("[GORM-Cache] Invalidated task list cache for user %d", userID) }() } + +func (p *GormCachePlugin) invalidSchedulePlanPreviewCache(userID int, conversationID string) { + normalizedConversationID := strings.TrimSpace(conversationID) + if userID == 0 || normalizedConversationID == "" { + return + } + go func() { + // 1. 这里的调用目的:当排程状态快照发生覆盖写入时,主动删除对应会话预览缓存。 + // 2. 这样可以避免“Redis 里还是旧预览,但 MySQL 已经是新快照”的短暂口径不一致。 + // 3. 失败策略:缓存删除失败只记日志,不影响主事务提交。 + if err := p.cacheDAO.DeleteSchedulePlanPreviewFromCache(context.Background(), userID, normalizedConversationID); err != nil { + log.Printf("[GORM-Cache] Failed to invalidate schedule preview cache for user %d conversation %s: %v", userID, normalizedConversationID, err) + return + } + log.Printf("[GORM-Cache] Invalidated schedule preview cache for user %d conversation %s", userID, normalizedConversationID) + }() +} diff --git a/backend/model/agent_schedule_state.go b/backend/model/agent_schedule_state.go new file mode 100644 index 0000000..a82316c --- /dev/null +++ b/backend/model/agent_schedule_state.go @@ -0,0 +1,85 @@ +package model + +import "time" + +const ( + // SchedulePlanStateVersionV1 表示当前 schedule_plan 快照结构版本。 + // + // 设计说明: + // 1. 当后续快照字段发生不兼容变更时,版本号用于区分反序列化逻辑; + // 2. 当前版本先固定为 1,后续升级时由写入端递增; + // 3. 读取端可依据版本做兼容兜底,避免历史快照直接失效。 + SchedulePlanStateVersionV1 = 1 +) + +// AgentScheduleState 是“单用户单会话”的智能排程状态快照持久化模型。 +// +// 职责边界: +// 1. 负责保存“可恢复的排程中间状态与最终预览”,用于连续对话微调承接; +// 2. 负责承载结构化 JSON 快照(任务类、混合条目、候选方案等); +// 3. 不负责正式日程落库(正式落库仍走你现有的确认/应用链路); +// 4. 不负责消息总线投递(该快照要求强实时可读,直接写 MySQL)。 +type AgentScheduleState struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + + // 1. 一对话一状态:同 user_id + conversation_id 永远只保留最新快照。 + // 2. revision 在 upsert 更新时自增,便于排查“同会话被覆盖了几次”。 + UserID int `gorm:"column:user_id;not null;uniqueIndex:uk_schedule_state_user_conv,priority:1;index:idx_schedule_state_user_updated,priority:1"` + ConversationID string `gorm:"column:conversation_id;type:varchar(36);not null;uniqueIndex:uk_schedule_state_user_conv,priority:2"` + Revision int `gorm:"column:revision;not null;default:1"` + StateVersion int `gorm:"column:state_version;not null;default:1"` + + // 3. 为了避免跨层结构体强耦合,复杂切片统一序列化为 JSON 字符串存储。 + TaskClassIDsJSON string `gorm:"column:task_class_ids;type:json;not null"` + ConstraintsJSON string `gorm:"column:constraints;type:json;not null"` + HybridEntriesJSON string `gorm:"column:hybrid_entries;type:json;not null"` + AllocatedItemsJSON string `gorm:"column:allocated_items;type:json;not null"` + CandidatePlansJSON string `gorm:"column:candidate_plans;type:json;not null"` + + // 4. 这组字段用于恢复“本轮策略语义”,支持后续在会话内连续微调。 + UserIntent string `gorm:"column:user_intent;type:text"` + Strategy string `gorm:"column:strategy;type:varchar(32);not null;default:steady"` + AdjustmentScope string `gorm:"column:adjustment_scope;type:varchar(16);not null;default:large"` + RestartRequested bool `gorm:"column:restart_requested;not null;default:false"` + + // 5. 这组字段用于预览展示与链路排障。 + FinalSummary string `gorm:"column:final_summary;type:text"` + Completed bool `gorm:"column:completed;not null;default:false"` + TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_schedule_state_trace_id"` + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index:idx_schedule_state_user_updated,priority:2"` +} + +func (AgentScheduleState) TableName() string { + return "agent_schedule_states" +} + +// SchedulePlanStateSnapshot 是服务层与 DAO 之间的快照传输结构(DTO)。 +// +// 职责边界: +// 1. 负责在 service 与 dao 之间传递“强类型快照”; +// 2. 由 DAO 负责把该结构序列化/反序列化为数据库 JSON 字段; +// 3. 不承载运行期临时字段(如并发信号、chan、上下文对象等)。 +type SchedulePlanStateSnapshot struct { + UserID int + ConversationID string + Revision int + StateVersion int + + TaskClassIDs []int + Constraints []string + HybridEntries []HybridScheduleEntry + AllocatedItems []TaskClassItem + CandidatePlans []UserWeekSchedule + + UserIntent string + Strategy string + AdjustmentScope string + RestartRequested bool + FinalSummary string + Completed bool + TraceID string + + UpdatedAt time.Time +} diff --git a/backend/service/agent_bridge.go b/backend/service/agent_bridge.go index e6f760b..fdac1c9 100644 --- a/backend/service/agent_bridge.go +++ b/backend/service/agent_bridge.go @@ -18,8 +18,8 @@ type AgentService = agentsvc.AgentService // 说明: // 1) 外部调用签名不变,新增排程依赖通过可选方式注入(见 NewAgentServiceWithSchedule); // 2) 真实构造逻辑已下沉到 service/agentsvc 包。 -func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService { - return agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher) +func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, cacheDAO *dao.CacheDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService { + return agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, eventPublisher) } // NewAgentServiceWithSchedule 在基础 AgentService 上注入排程依赖。 @@ -32,11 +32,12 @@ func NewAgentServiceWithSchedule( aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, + cacheDAO *dao.CacheDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher, scheduleSvc *ScheduleService, ) *AgentService { - svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, agentRedis, eventPublisher) + svc := agentsvc.NewAgentService(aiHub, repo, taskRepo, cacheDAO, agentRedis, eventPublisher) // 注入排程依赖:将 service 层方法包装为函数闭包,避免循环依赖。 if scheduleSvc != nil { diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index f40a335..1392ab5 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -25,6 +25,7 @@ type AgentService struct { AIHub *inits.AIHub repo *dao.AgentDAO taskRepo *dao.TaskDAO + cacheDAO *dao.CacheDAO agentCache *dao.AgentCache eventPublisher outboxinfra.EventPublisher @@ -49,7 +50,7 @@ type AgentService struct { // NewAgentService 构造 AgentService。 // 这里通过依赖注入把“模型、仓储、缓存、异步持久化通道”统一交给服务层管理, // 便于后续在单测中替换实现,或在启动流程中按环境切换配置。 -func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService { +func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskDAO, cacheDAO *dao.CacheDAO, agentRedis *dao.AgentCache, eventPublisher outboxinfra.EventPublisher) *AgentService { // 全局注册一次 token 采集 callback: // 1. 只注册一次,避免重复处理; // 2. 只有带 RequestTokenMeter 的请求上下文才会真正累加。 @@ -59,6 +60,7 @@ func NewAgentService(aiHub *inits.AIHub, repo *dao.AgentDAO, taskRepo *dao.TaskD AIHub: aiHub, repo: repo, taskRepo: taskRepo, + cacheDAO: cacheDAO, agentCache: agentRedis, eventPublisher: eventPublisher, } diff --git a/backend/service/agentsvc/agent_schedule_plan.go b/backend/service/agentsvc/agent_schedule_plan.go index 3ecde38..58d4e4b 100644 --- a/backend/service/agentsvc/agent_schedule_plan.go +++ b/backend/service/agentsvc/agent_schedule_plan.go @@ -51,17 +51,34 @@ func (s *AgentService) runSchedulePlanFlow( // 2.1.2 先读可让本轮在内存中复用上轮 HybridEntries。 // 2.2 清理旧 key 仍然保留,避免前端在本轮进行中误读到旧结果。 var previousPreview *model.SchedulePlanPreviewCache - if s.agentCache != nil { - preview, getErr := s.agentCache.GetSchedulePlanPreview(ctx, userID, chatID) + if s.cacheDAO != nil { + preview, getErr := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, chatID) if getErr != nil { log.Printf("读取上一版排程预览失败 chat_id=%s: %v", chatID, getErr) } else { previousPreview = preview } - if delErr := s.agentCache.DeleteSchedulePlanPreview(ctx, userID, chatID); delErr != nil { + if delErr := s.cacheDAO.DeleteSchedulePlanPreviewFromCache(ctx, userID, chatID); delErr != nil { log.Printf("清理旧排程预览失败 chat_id=%s: %v", chatID, delErr) } } + // 2.3 Redis miss 时回落 MySQL 快照: + // 2.3.1 目的:即使 Redis TTL 过期,也能延续同会话微调语境; + // 2.3.2 回填:命中 DB 后尝试回填 Redis,提高后续读取命中率; + // 2.3.3 失败策略:DB 读取异常只打日志,链路继续按“无历史快照”执行。 + if previousPreview == nil && s.repo != nil { + snapshot, snapshotErr := s.repo.GetScheduleStateSnapshot(ctx, userID, chatID) + if snapshotErr != nil { + log.Printf("从 MySQL 读取排程快照失败 chat_id=%s: %v", chatID, snapshotErr) + } else if snapshot != nil { + previousPreview = snapshotToSchedulePlanPreviewCache(snapshot) + if s.cacheDAO != nil && previousPreview != nil { + if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, chatID, previousPreview); setErr != nil { + log.Printf("回填排程预览缓存失败 chat_id=%s: %v", chatID, setErr) + } + } + } + } // 3. 读取对话历史:先快后稳。 // 3.1 先查 Redis,命中则避免回源 DB,降低请求时延。 @@ -99,6 +116,7 @@ func (s *AgentService) runSchedulePlanFlow( state.PreviousTaskClassIDs = append([]int(nil), previousPreview.TaskClassIDs...) state.PreviousHybridEntries = cloneHybridEntries(previousPreview.HybridEntries) state.PreviousAllocatedItems = cloneTaskClassItems(previousPreview.AllocatedItems) + state.PreviousCandidatePlans = cloneWeekSchedules(previousPreview.CandidatePlans) } finalState, runErr := scheduleplan.RunSchedulePlanGraph(ctx, scheduleplan.SchedulePlanGraphRunInput{ Model: selectedModel, diff --git a/backend/service/agentsvc/agent_schedule_preview.go b/backend/service/agentsvc/agent_schedule_preview.go index 1eec7b9..b634f14 100644 --- a/backend/service/agentsvc/agent_schedule_preview.go +++ b/backend/service/agentsvc/agent_schedule_preview.go @@ -19,8 +19,8 @@ import ( // 2. 负责以“失败不阻断聊天主链路”的策略执行写入; // 3. 不负责 SSE 返回协议,不负责数据库落库。 func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *scheduleplan.SchedulePlanState) { - // 1. 基础前置校验:任何关键依赖缺失都直接返回,避免产生无意义错误日志。 - if s == nil || s.agentCache == nil || finalState == nil { + // 1. 基础前置校验:state 为空时直接返回,避免写入半成品快照。 + if s == nil || finalState == nil { return } normalizedChatID := strings.TrimSpace(chatID) @@ -48,11 +48,24 @@ func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, GeneratedAt: time.Now(), } - // 3. 尝试写入缓存: - // 3.1 写入失败仅打日志,不上抛错误,保证聊天接口协议与可用性不受影响; - // 3.2 兜底策略是“用户仍可收到文本摘要”,只是暂时无法通过新接口拉取结构化预览。 - if err := s.agentCache.SetSchedulePlanPreview(ctx, userID, normalizedChatID, preview); err != nil { - log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err) + // 3. 调用目的:先写 Redis 预览,保证前端查询接口能快速读取结构化结果。 + // 3.1 Redis 是“快路径”;失败只记录日志,不中断主链路; + // 3.2 失败兜底由后续 MySQL 快照承接。 + 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 这里采用“同步写库”而不是 outbox:因为下一轮微调要强实时读取; + // 4.2 快照写入失败只打日志,不阻断本轮用户回复,避免体验抖动; + // 4.3 revision 自增由 DAO 的 upsert 冲突更新负责。 + if s.repo != nil { + snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState) + if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil { + log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err) + } } } @@ -68,37 +81,58 @@ func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, c if normalizedChatID == "" { return nil, respond.MissingParam } - if s == nil || s.agentCache == nil { - return nil, errors.New("agent cache is not initialized") + if s == nil { + return nil, errors.New("agent service is not initialized") } // 2. 查询缓存并校验归属: // 2.1 缓存未命中:统一返回“预览不存在/已过期”; // 2.2 命中但 user_id 不一致:按未命中处理,避免泄露他人会话信息; // 2.3 失败兜底:缓存读异常直接上抛,由 API 层统一错误处理。 - preview, err := s.agentCache.GetSchedulePlanPreview(ctx, userID, normalizedChatID) - if err != nil { - return nil, err - } - if preview == nil { - return nil, respond.SchedulePlanPreviewNotFound - } - if preview.UserID > 0 && preview.UserID != userID { - return nil, respond.SchedulePlanPreviewNotFound + if s.cacheDAO != nil { + preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID) + if err != nil { + return nil, err + } + if preview != nil { + if preview.UserID > 0 && preview.UserID != userID { + return nil, respond.SchedulePlanPreviewNotFound + } + plans := cloneWeekSchedules(preview.CandidatePlans) + if plans == nil { + plans = make([]model.UserWeekSchedule, 0) + } + return &model.GetSchedulePlanPreviewResponse{ + ConversationID: normalizedChatID, + TraceID: strings.TrimSpace(preview.TraceID), + Summary: strings.TrimSpace(preview.Summary), + CandidatePlans: plans, + GeneratedAt: preview.GeneratedAt, + }, nil + } } - // 3. 映射响应结构,保证输出字段稳定。 - plans := cloneWeekSchedules(preview.CandidatePlans) - if plans == nil { - plans = make([]model.UserWeekSchedule, 0) + // 3. Redis 未命中时回落 MySQL 快照: + // 3.1 读取成功后直接返回,避免用户看到“预览不存在”的假阴性; + // 3.2 若本次命中 DB 且缓存可用,则顺手回填 Redis,提升后续命中率; + // 3.3 DB 也未命中时再返回 not found。 + if s.repo != nil { + snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID) + if err != nil { + return nil, err + } + if snapshot != nil { + response := snapshotToSchedulePlanPreviewResponse(snapshot) + if s.cacheDAO != nil { + cachePreview := snapshotToSchedulePlanPreviewCache(snapshot) + if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, cachePreview); setErr != nil { + log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr) + } + } + return response, nil + } } - return &model.GetSchedulePlanPreviewResponse{ - ConversationID: normalizedChatID, - TraceID: strings.TrimSpace(preview.TraceID), - Summary: strings.TrimSpace(preview.Summary), - CandidatePlans: plans, - GeneratedAt: preview.GeneratedAt, - }, nil + return nil, respond.SchedulePlanPreviewNotFound } // cloneWeekSchedules 对周视图排程结果做深拷贝,避免切片引用共享。 @@ -160,3 +194,84 @@ func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { } return dst } + +// buildSchedulePlanSnapshotFromState 把 graph 运行结果映射成可持久化快照 DTO。 +// +// 职责边界: +// 1. 负责字段映射与深拷贝,避免跨层共享可变切片; +// 2. 负责补齐 state_version 默认值; +// 3. 不负责数据库写入(写入由 DAO 承担)。 +func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *scheduleplan.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 { + return nil + } + summary := strings.TrimSpace(snapshot.FinalSummary) + if summary == "" { + summary = "排程流程已完成,但未生成结果摘要。" + } + generatedAt := snapshot.UpdatedAt + if generatedAt.IsZero() { + generatedAt = time.Now() + } + return &model.SchedulePlanPreviewCache{ + UserID: snapshot.UserID, + ConversationID: snapshot.ConversationID, + TraceID: strings.TrimSpace(snapshot.TraceID), + Summary: summary, + CandidatePlans: cloneWeekSchedules(snapshot.CandidatePlans), + TaskClassIDs: append([]int(nil), snapshot.TaskClassIDs...), + HybridEntries: cloneHybridEntries(snapshot.HybridEntries), + AllocatedItems: cloneTaskClassItems(snapshot.AllocatedItems), + GeneratedAt: generatedAt, + } +} + +// snapshotToSchedulePlanPreviewResponse 把 MySQL 快照转换为查询接口响应。 +func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnapshot) *model.GetSchedulePlanPreviewResponse { + if snapshot == nil { + return nil + } + plans := cloneWeekSchedules(snapshot.CandidatePlans) + if plans == nil { + plans = make([]model.UserWeekSchedule, 0) + } + summary := strings.TrimSpace(snapshot.FinalSummary) + if summary == "" { + summary = "排程流程已完成,但未生成结果摘要。" + } + generatedAt := snapshot.UpdatedAt + if generatedAt.IsZero() { + generatedAt = time.Now() + } + return &model.GetSchedulePlanPreviewResponse{ + ConversationID: snapshot.ConversationID, + TraceID: strings.TrimSpace(snapshot.TraceID), + Summary: summary, + CandidatePlans: plans, + GeneratedAt: generatedAt, + } +} diff --git a/infra/schedule_preview_viewer.html b/infra/schedule_preview_viewer.html new file mode 100644 index 0000000..55866b3 --- /dev/null +++ b/infra/schedule_preview_viewer.html @@ -0,0 +1,768 @@ + + + + + + SmartFlow 排程预览调试页 + + + +
+
+
+

排程预览 JSON 输入

+

粘贴 /api/v1/agent/schedule-preview 响应,点击“解析并渲染”。

+
+
+ +
+ + + +
+
+
+
+ +
+
+
+ + + conversation_id: - + trace_id: - + generated_at: - +
+
这里会显示排程摘要。
+
+ existing(课程/已存在安排) + suggested(建议任务) + task(普通任务) + 嵌入任务(显示在课程块内) +
+
+
+
先粘贴 JSON 再渲染课表。
+
+
+
+ + + +