Version: 0.7.2.dev.260322
feat(schedule-plan): ✨ 重构智能排程链路并修复粗排双节对齐问题 - ✨ 新增“对话级排程状态持久化”能力:引入 `agent_schedule_states` 模型/DAO,并接入启动迁移 - ✨ 智能排程图升级:补齐小幅微调(quick refine)分支,完善预算/并发/状态字段流转 - ✨ 预览链路增强:完善排程预览服务读写与桥接逻辑,新增本地预览页 `infra/schedule_preview_viewer.html` - ♻️ 缓存治理统一:将相关缓存处理收口到 DAO + `cache_deleter` 联动清理,移除旧散落逻辑 - 🐛 修复粗排核心 bug:禁止单节降级,强制双节并按 `1-2/3-4/...` 对齐;修复结束日扫描边界问题 - ✅ 新增粗排回归测试:覆盖孤立单节、偶数起点双节、Filler 对齐等关键场景
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 是否表示同一集合(忽略顺序,忽略重复)。
|
||||
//
|
||||
// 语义:
|
||||
|
||||
@@ -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 节点。
|
||||
|
||||
77
backend/agent/scheduleplan/quick_refine.go
Normal file
77
backend/agent/scheduleplan/quick_refine.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
252
backend/dao/agent_schedule_state.go
Normal file
252
backend/dao/agent_schedule_state.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func autoMigrateModels(db *gorm.DB) error {
|
||||
&model.ScheduleEvent{},
|
||||
&model.Schedule{},
|
||||
&model.AgentOutboxMessage{},
|
||||
&model.AgentScheduleState{},
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
154
backend/logic/smart_planning_test.go
Normal file
154
backend/logic/smart_planning_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
85
backend/model/agent_schedule_state.go
Normal file
85
backend/model/agent_schedule_state.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user