diff --git a/backend/conv/schedule_preview.go b/backend/conv/schedule_preview.go index 969cded..7411d25 100644 --- a/backend/conv/schedule_preview.go +++ b/backend/conv/schedule_preview.go @@ -30,7 +30,7 @@ func ScheduleStateToPreview( for i := range state.Tasks { t := &state.Tasks[i] // 待安排且无位置的任务不生成 entry。 - if t.Status == "pending" && len(t.Slots) == 0 { + if newagenttools.IsPendingTask(*t) { continue } @@ -59,7 +59,7 @@ func ScheduleStateToPreview( entry.Type = "task" } - // Status 映射:existing 不变,pending(有位置)= suggested。 + // Status 映射:existing 不变,suggested / 兼容建议态统一输出为 suggested。 if shouldMarkSuggestedInPreview(*t) { entry.Status = "suggested" } else { @@ -113,15 +113,9 @@ func ScheduleStateToPreview( // shouldMarkSuggestedInPreview 判断某条 ScheduleTask 在预览层是否应标记为 suggested。 // // 规则说明: -// 1. pending 任务在预览语义中属于“建议态”; -// 2. source=task_item 且 Duration>0 的任务来自待排任务池, -// 即使工具层在 place 后把它改成 existing,预览层也要继续按 suggested 输出。 +// 1. 新语义下,显式 suggested 直接输出为建议态; +// 2. 兼容旧快照:pending+Slots、existing+Duration>0 的 task_item 也继续按 suggested 输出; +// 3. 这样前端预览口径可以在迁移期保持稳定,不会因为状态枚举切换而抖动。 func shouldMarkSuggestedInPreview(t newagenttools.ScheduleTask) bool { - if t.Status == "pending" { - return true - } - if t.Source == "task_item" && t.Duration > 0 { - return true - } - return false + return newagenttools.IsSuggestedTask(t) } diff --git a/backend/conv/schedule_provider.go b/backend/conv/schedule_provider.go index d71175b..72ccb2d 100644 --- a/backend/conv/schedule_provider.go +++ b/backend/conv/schedule_provider.go @@ -45,22 +45,54 @@ func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (* return nil, err } - // 2. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。 - windowDays, weeks := buildWindowFromTaskClasses(taskClasses) - if len(windowDays) == 0 { - now := time.Now() - currentWeek, _, err := RealDateToRelativeDate(now.Format(DateFormat)) - if err != nil { - return nil, fmt.Errorf("解析当前日期失败: %w", err) - } - windowDays = make([]WindowDay, 7) - for i := 0; i < 7; i++ { - windowDays[i] = WindowDay{Week: currentWeek, DayOfWeek: i + 1} - } - weeks = []int{currentWeek} + return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses) +} + +// LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。 +// +// 设计说明: +// 1. 负责:让粗排 / Execute 首次读取的 DayMapping 与本轮 task_class_ids 保持同一时间窗口; +// 2. 不负责:裁掉窗口内已有的 existing/suggested 阻塞物,这部分仍由日程加载主流程统一保留; +// 3. 失败策略:若 task_class_ids 为空,则退回全量加载,避免调用方额外分支。 +func (p *ScheduleProvider) LoadScheduleStateForTaskClasses( + ctx context.Context, + userID int, + taskClassIDs []int, +) (*newagenttools.ScheduleState, error) { + if len(taskClassIDs) == 0 { + return p.LoadScheduleState(ctx, userID) } - // 3. 按周加载日程(含 Event + EmbeddedTask 预加载)。 + taskClasses, err := p.loadCompleteTaskClassesByIDs(ctx, userID, taskClassIDs) + if err != nil { + return nil, err + } + + return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses) +} + +// loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。 +// +// 步骤说明: +// 1. 先根据传入 taskClasses 计算 DayMapping 窗口,保证粗排坐标能映射回 day_index; +// 2. 若窗口无法从任务类日期推导,则退回当前周 7 天,兼容普通查询场景; +// 3. 再按窗口覆盖的周批量拉取 existing schedules,与 taskClasses 一起交给 LoadScheduleState 统一建模。 +func (p *ScheduleProvider) loadScheduleStateWithTaskClasses( + ctx context.Context, + userID int, + taskClasses []model.TaskClass, +) (*newagenttools.ScheduleState, error) { + // 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。 + windowDays, weeks := buildWindowFromTaskClasses(taskClasses) + if len(windowDays) == 0 { + var err error + windowDays, weeks, err = buildCurrentWeekWindow() + if err != nil { + return nil, err + } + } + + // 2. 按周加载日程(含 Event + EmbeddedTask 预加载)。 var allSchedules []model.Schedule for _, w := range weeks { weekSchedules, err := p.scheduleDAO.GetUserWeeklySchedule(ctx, userID, w) @@ -70,10 +102,10 @@ func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (* allSchedules = append(allSchedules, weekSchedules...) } - // 4. 构建额外 item category 映射。 + // 3. 构建额外 item category 映射。 extraItemCategories := buildExtraItemCategories(allSchedules, taskClasses) - // 5. 调用已有的 LoadScheduleState 构建内存状态。 + // 4. 调用已有的 LoadScheduleState 构建内存状态。 return LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil } @@ -84,36 +116,43 @@ func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (* // - weeks:窗口覆盖的周号(去重、升序),供按周加载日程使用; // - 若无有效日期信息,返回空切片,调用方应降级到默认窗口。 func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) { - var minDate, maxDate *time.Time - for _, tc := range taskClasses { - if tc.StartDate != nil && (minDate == nil || tc.StartDate.Before(*minDate)) { - t := *tc.StartDate - minDate = &t - } - if tc.EndDate != nil && (maxDate == nil || tc.EndDate.After(*maxDate)) { - t := *tc.EndDate - maxDate = &t - } - } - if minDate == nil || maxDate == nil { - return nil, nil - } + minWeek, minDay := 0, 0 + maxWeek, maxDay := 0, 0 + hasWindow := false - startWeek, startDay, err := RealDateToRelativeDate(minDate.Format(DateFormat)) - if err != nil { - return nil, nil + for _, tc := range taskClasses { + // 1. 先要求任务类具备完整且合法的起止日期,避免坏数据把整轮窗口拖坏。 + // 2. 再逐条做绝对日期 -> 相对周/天转换;转换失败的任务类直接忽略,不影响其余合法任务类。 + // 3. 只有至少一条任务类成功进入窗口后,才返回有效 DayMapping。 + if tc.StartDate == nil || tc.EndDate == nil || tc.EndDate.Before(*tc.StartDate) { + continue + } + startWeek, startDay, err := RealDateToRelativeDate(tc.StartDate.Format(DateFormat)) + if err != nil { + continue + } + endWeek, endDay, err := RealDateToRelativeDate(tc.EndDate.Format(DateFormat)) + if err != nil { + continue + } + if !hasWindow || isRelativeDateBefore(startWeek, startDay, minWeek, minDay) { + minWeek, minDay = startWeek, startDay + } + if !hasWindow || isRelativeDateBefore(maxWeek, maxDay, endWeek, endDay) { + maxWeek, maxDay = endWeek, endDay + } + hasWindow = true } - endWeek, endDay, err := RealDateToRelativeDate(maxDate.Format(DateFormat)) - if err != nil { + if !hasWindow { return nil, nil } weeksSet := make(map[int]bool) - w, d := startWeek, startDay + w, d := minWeek, minDay for { windowDays = append(windowDays, WindowDay{Week: w, DayOfWeek: d}) weeksSet[w] = true - if w == endWeek && d == endDay { + if w == maxWeek && d == maxDay { break } d++ @@ -121,7 +160,7 @@ func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []Win d = 1 w++ } - if w > endWeek+1 { // 防止因日期转换异常导致无限循环 + if w > maxWeek+1 { // 防止因日期转换异常导致无限循环 break } } @@ -134,6 +173,28 @@ func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []Win return windowDays, weeks } +// buildCurrentWeekWindow 构造“当前周 7 天”的兜底窗口。 +func buildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) { + now := time.Now() + currentWeek, _, err := RealDateToRelativeDate(now.Format(DateFormat)) + if err != nil { + return nil, nil, fmt.Errorf("解析当前日期失败: %w", err) + } + windowDays = make([]WindowDay, 7) + for i := 0; i < 7; i++ { + windowDays[i] = WindowDay{Week: currentWeek, DayOfWeek: i + 1} + } + return windowDays, []int{currentWeek}, nil +} + +// isRelativeDateBefore 比较两个“相对周/天”坐标的先后关系。 +func isRelativeDateBefore(leftWeek, leftDay, rightWeek, rightDay int) bool { + if leftWeek != rightWeek { + return leftWeek < rightWeek + } + return leftDay < rightDay +} + // loadCompleteTaskClasses 批量加载用户所有任务类(含 Items 预加载)。 func (p *ScheduleProvider) loadCompleteTaskClasses(ctx context.Context, userID int) ([]model.TaskClass, error) { basicClasses, err := p.taskClassDAO.GetUserTaskClasses(userID) @@ -156,6 +217,23 @@ func (p *ScheduleProvider) loadCompleteTaskClasses(ctx context.Context, userID i return complete, nil } +// loadCompleteTaskClassesByIDs 批量加载指定任务类(含 Items 预加载)。 +func (p *ScheduleProvider) loadCompleteTaskClassesByIDs( + ctx context.Context, + userID int, + taskClassIDs []int, +) ([]model.TaskClass, error) { + if len(taskClassIDs) == 0 { + return nil, nil + } + + complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs) + if err != nil { + return nil, fmt.Errorf("加载指定任务类失败: %w", err) + } + return complete, nil +} + // LoadTaskClassMetas 加载指定任务类的约束元数据(不含 Items、不含日程),供 Plan 阶段提前消费。 func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]newagenttools.TaskClassMeta, error) { if len(taskClassIDs) == 0 { diff --git a/backend/newAgent/model/graph_run_state.go b/backend/newAgent/model/graph_run_state.go index c3acd78..46e35e1 100644 --- a/backend/newAgent/model/graph_run_state.go +++ b/backend/newAgent/model/graph_run_state.go @@ -241,6 +241,7 @@ func (s *AgentGraphState) EnsureScheduleState(ctx context.Context) (*newagenttoo if s == nil { return nil, nil } + flowState := s.EnsureFlowState() if s.ScheduleState != nil { if s.OriginalScheduleState == nil { // 1. 兼容老快照:历史 Redis 快照里可能还没带 original_state。 @@ -248,18 +249,33 @@ func (s *AgentGraphState) EnsureScheduleState(ctx context.Context) (*newagenttoo // 3. 因此这里在“已恢复出 ScheduleState、但缺 original”时补一份克隆兜底。 s.OriginalScheduleState = s.ScheduleState.Clone() } + newagenttools.FilterScheduleStateForTaskClassScope(s.ScheduleState, flowState.TaskClassIDs) + newagenttools.FilterScheduleStateForTaskClassScope(s.OriginalScheduleState, flowState.TaskClassIDs) return s.ScheduleState, nil } if s.Deps.ScheduleProvider == nil { return nil, nil } - userID := s.EnsureFlowState().UserID - state, err := s.Deps.ScheduleProvider.LoadScheduleState(ctx, userID) + userID := flowState.UserID + var ( + state *newagenttools.ScheduleState + err error + ) + // 1. 若 provider 支持按 task_class_ids 精确加载,则优先走 scoped 入口。 + // 2. 这样可以让 DayMapping 与粗排算法使用同一批任务类窗口,避免“全量任务类脏日期污染本轮窗口”。 + // 3. 若当前实现尚未支持 scoped 加载,则回退到旧入口,并继续复用后面的 scope 裁剪。 + if scopedProvider, ok := s.Deps.ScheduleProvider.(ScopedScheduleStateProvider); ok && len(flowState.TaskClassIDs) > 0 { + state, err = scopedProvider.LoadScheduleStateForTaskClasses(ctx, userID, flowState.TaskClassIDs) + } else { + state, err = s.Deps.ScheduleProvider.LoadScheduleState(ctx, userID) + } if err != nil { return nil, err } s.ScheduleState = state // 保存原始快照,供后续 diff 使用。 s.OriginalScheduleState = state.Clone() + newagenttools.FilterScheduleStateForTaskClassScope(s.ScheduleState, flowState.TaskClassIDs) + newagenttools.FilterScheduleStateForTaskClassScope(s.OriginalScheduleState, flowState.TaskClassIDs) return state, nil } diff --git a/backend/newAgent/model/state_store.go b/backend/newAgent/model/state_store.go index 4b90dca..07de4f6 100644 --- a/backend/newAgent/model/state_store.go +++ b/backend/newAgent/model/state_store.go @@ -63,6 +63,16 @@ type ScheduleStateProvider interface { LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]newagenttools.TaskClassMeta, error) } +// ScopedScheduleStateProvider 定义“按本轮任务类范围加载 ScheduleState”的可选增强接口。 +// +// 设计说明: +// 1. 负责:在 Execute / RoughBuild 首次加载状态时,把 DayMapping、TaskClasses 与 pending 任务限定在本轮 task_class_ids 相关窗口; +// 2. 不负责:改变既有 ScheduleStateProvider 的基础能力,老实现仍可只实现 LoadScheduleState; +// 3. 兜底策略:若调用方拿到的 provider 不实现该接口,则回退到全量 LoadScheduleState,再走工具层 scope 裁剪。 +type ScopedScheduleStateProvider interface { + LoadScheduleStateForTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (*newagenttools.ScheduleState, error) +} + // SchedulePersistor 定义持久化 ScheduleState 变更的接口。 // 由 Service 层或 DAO 层实现,注入到 AgentGraphDeps 中。 // 使用接口而非具体 DAO 类型,避免 model → dao 的循环依赖。 diff --git a/backend/newAgent/node/rough_build.go b/backend/newAgent/node/rough_build.go index e369aa3..7f416eb 100644 --- a/backend/newAgent/node/rough_build.go +++ b/backend/newAgent/node/rough_build.go @@ -3,6 +3,7 @@ package newagentnode import ( "context" "fmt" + "log" "strconv" "strings" @@ -13,8 +14,17 @@ import ( const ( roughBuildStageName = "rough_build" roughBuildStatusBlock = "rough_build.status" + roughBuildSampleLimit = 3 ) +type roughBuildApplyStats struct { + AppliedCount int + DayMappingMissCount int + TaskItemMatchMissCount int + DayMappingMissSamples []string + TaskItemMatchMissSamples []string +} + // RunRoughBuildNode 执行粗排节点逻辑。 // // 步骤说明: @@ -22,8 +32,9 @@ const ( // 2. 从 CommonState 读取 TaskClassIDs,确认有需要排课的任务类; // 3. 加载 ScheduleState(含 DayMapping); // 4. 调用 RoughBuildFunc 拿到粗排结果([]RoughBuildPlacement); -// 5. 把粗排结果写入 ScheduleState 的对应 task.Slots(pending 任务预填位置); -// 6. 推送"粗排完成"状态,清除 NeedsRoughBuild 标记,进入执行阶段。 +// 5. 把粗排结果写入 ScheduleState,把已落位任务标记为 suggested; +// 6. 若粗排后仍存在真实 pending,则写入正式 abort 结果并结束本轮; +// 7. 否则推送"粗排完成"状态,清除 NeedsRoughBuild 标记,进入执行阶段。 func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) error { if st == nil { return fmt.Errorf("rough build node: state is nil") @@ -71,9 +82,58 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e } // 6. 把粗排结果写入 ScheduleState。 - applyRoughBuildPlacements(scheduleState, placements) + applyStats := applyRoughBuildPlacements(scheduleState, placements) - // 7. 推送完成状态。 + // 7. 先校验粗排后是否仍有真实 pending。 + stillPending := countPendingTasks(scheduleState, taskClassIDs) + log.Printf( + "[DEBUG] rough_build scope_task_classes=%v placements=%d applied=%d day_mapping_miss=%d task_item_match_miss=%d pending_in_scope=%d total_tasks=%d window_days=%d", + taskClassIDs, + len(placements), + applyStats.AppliedCount, + applyStats.DayMappingMissCount, + applyStats.TaskItemMatchMissCount, + stillPending, + len(scheduleState.Tasks), + len(scheduleState.Window.DayMapping), + ) + if applyStats.DayMappingMissCount > 0 { + log.Printf( + "[DEBUG] rough_build day_mapping_miss_samples=%v window=%s", + applyStats.DayMappingMissSamples, + summarizeRoughBuildWindow(scheduleState), + ) + } + if applyStats.TaskItemMatchMissCount > 0 { + log.Printf( + "[DEBUG] rough_build task_item_match_miss_samples=%v scoped_task_samples=%v", + applyStats.TaskItemMatchMissSamples, + collectScopedTaskSamples(scheduleState, taskClassIDs), + ) + } + if stillPending > 0 { + failureMessage := fmt.Sprintf( + "初始排课方案构建异常:粗排后仍有 %d 个任务未获得初始落位。按当前规则,本轮不进入微调,请检查粗排算法或任务数据。", + stillPending, + ) + _ = emitter.EmitStatus( + roughBuildStatusBlock, + roughBuildStageName, + "rough_build_failed", + failureMessage, + true, + ) + flowState.NeedsRoughBuild = false + flowState.Abort( + roughBuildStageName, + "rough_build_pending_remaining", + failureMessage, + fmt.Sprintf("rough build finished with %d real pending tasks remaining", stillPending), + ) + return nil + } + + // 8. 推送完成状态。 _ = emitter.EmitStatus( roughBuildStatusBlock, roughBuildStageName, @@ -82,8 +142,7 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e false, ) - // 8. 把粗排完成信息写入 pinned context,让 Execute 阶段的 LLM 直接进入验证和微调。 - stillPending := countPendingTasks(scheduleState) + // 9. 把粗排完成信息写入 pinned context,让 Execute 阶段的 LLM 直接进入查看和微调。 // 构造任务类 ID 字符串,供 pinned block 明确标注,避免 Execute LLM 因找不到 task_class_id 来源而 ask_user。 idParts := make([]string, len(taskClassIDs)) @@ -92,34 +151,20 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e } idStr := strings.Join(idParts, ", ") - var pinnedContent string - if stillPending > 0 { - pinnedContent = fmt.Sprintf( - "后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+ - "注意:仍有 %d 个任务未被粗排覆盖,处于待安排(pending)状态,必须在微调阶段手动安排完毕。\n\n"+ - "处理 pending 任务的正确操作顺序:\n"+ - "1. 调用 get_overview 或 find_free 确认可用空位(不要反复调用 list_tasks,list_tasks 只能看任务列表,看不出空位)\n"+ - "2. 调用 place 将 pending 任务放入空位\n"+ - "3. 重复上述步骤,直到 get_overview 显示待安排任务剩余为 0\n\n"+ - "微调完成的判定标准:所有 pending 任务均已 place(待安排任务剩余=0),且现有排课无明显失衡。\n"+ - "无需再次触发粗排。", - idStr, len(placements), stillPending, - ) - } else { - pinnedContent = fmt.Sprintf( - "后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排,无待安排任务)。\n"+ - "请直接调用 get_overview 查看预排结果,然后用 move/swap 微调不合理的位置。\n"+ - "无需再次触发粗排。", - idStr, len(placements), - ) - } + pinnedContent := fmt.Sprintf( + "后端已自动运行粗排算法(任务类 ID:[%s]),初始排课方案已写入日程状态(共 %d 个任务已预排)。\n"+ + "这些预排任务已标记为 suggested,表示“可继续优化的建议落位”,不是待补排任务。\n"+ + "请先调用 get_overview 查看整体分布,再使用 move / swap / unplace 微调不合理的位置。\n"+ + "本轮不需要再调用 place,也无需再次触发粗排。", + idStr, len(placements), + ) st.EnsureConversationContext().UpsertPinnedBlock(newagentmodel.ContextBlock{ Key: "rough_build_done", Title: "粗排已完成", Content: pinnedContent, }) - // 9. 清除标记,进入执行阶段。 + // 10. 清除标记,进入执行阶段。 flowState.NeedsRoughBuild = false flowState.Phase = newagentmodel.PhaseExecuting return nil @@ -127,16 +172,24 @@ func RunRoughBuildNode(ctx context.Context, st *newagentmodel.AgentGraphState) e // countPendingTasks 统计粗排后仍无位置的待安排任务数。 // -// 粗排只设 Slots,不改 Status(仍为 "pending"), -// 所以"真正未覆盖"= pending 且 Slots 为空,需要手动 place。 -func countPendingTasks(state *newagenttools.ScheduleState) int { +// 说明: +// 1. 第一轮修复后,粗排成功会把任务直接标记为 suggested; +// 2. 为兼容旧快照,仍按“pending 且 Slots 为空”认定真正未覆盖; +// 3. 只要这里仍大于 0,就应视为粗排异常,而不是交给 LLM 补排。 +func countPendingTasks(state *newagenttools.ScheduleState, taskClassIDs []int) int { if state == nil { return 0 } count := 0 for i := range state.Tasks { - t := &state.Tasks[i] - if t.Status == "pending" && len(t.Slots) == 0 { + task := state.Tasks[i] + if !newagenttools.IsPendingTask(task) { + continue + } + if len(taskClassIDs) > 0 && !newagenttools.IsTaskInRequestedClassScope(task, taskClassIDs) { + continue + } + if newagenttools.IsPendingTask(task) { count++ } } @@ -148,27 +201,110 @@ func countPendingTasks(state *newagenttools.ScheduleState) int { // 设计说明: // 1. 通过 task_item_id(SourceID)定位任务; // 2. 用 DayMapping 把 (week, dayOfWeek) 转为 day_index; -// 3. task.Status 保持 "pending",让 LLM 在 Execute 阶段看到"有建议位置的待安排任务", -// 可用 move/swap 微调,也可用 unplace 推翻粗排结果; -// 4. 转换失败的条目静默跳过,不中断整体流程。 -func applyRoughBuildPlacements(state *newagenttools.ScheduleState, placements []newagentmodel.RoughBuildPlacement) { +// 3. 对成功落位的任务写入 Slots,并显式标记为 suggested; +// 4. suggested 表示“粗排建议位”,后续可用 move/swap/unplace 微调; +// 5. 转换失败的条目静默跳过,不中断整体流程。 +func applyRoughBuildPlacements( + state *newagenttools.ScheduleState, + placements []newagentmodel.RoughBuildPlacement, +) roughBuildApplyStats { + stats := roughBuildApplyStats{} if state == nil { - return + return stats } + + taskIndexByItemID := make(map[int][]int) + for i := range state.Tasks { + task := state.Tasks[i] + if task.Source != "task_item" { + continue + } + taskIndexByItemID[task.SourceID] = append(taskIndexByItemID[task.SourceID], i) + } + for _, p := range placements { day, ok := state.WeekDayToDay(p.Week, p.DayOfWeek) if !ok { + stats.DayMappingMissCount++ + stats.DayMappingMissSamples = appendPlacementSample(stats.DayMappingMissSamples, p) continue // DayMapping 里没有对应 day,跳过 } - for i := range state.Tasks { - t := &state.Tasks[i] - if t.Source != "task_item" || t.SourceID != p.TaskItemID { - continue - } + + matched := false + for _, index := range taskIndexByItemID[p.TaskItemID] { + t := &state.Tasks[index] t.Slots = []newagenttools.TaskSlot{ {Day: day, SlotStart: p.SectionFrom, SlotEnd: p.SectionTo}, } + t.Status = newagenttools.TaskStatusSuggested + stats.AppliedCount++ + matched = true + break + } + if !matched { + stats.TaskItemMatchMissCount++ + stats.TaskItemMatchMissSamples = appendPlacementSample(stats.TaskItemMatchMissSamples, p) + } + } + return stats +} + +// appendPlacementSample 记录有限数量的 miss 样本,避免 debug 日志爆量。 +func appendPlacementSample(samples []string, placement newagentmodel.RoughBuildPlacement) []string { + if len(samples) >= roughBuildSampleLimit { + return samples + } + return append(samples, fmt.Sprintf( + "task_item_id=%d week=%d day=%d sections=%d-%d", + placement.TaskItemID, + placement.Week, + placement.DayOfWeek, + placement.SectionFrom, + placement.SectionTo, + )) +} + +// summarizeRoughBuildWindow 提供 DayMapping 的紧凑摘要,便于判断窗口是否退化到错误周。 +func summarizeRoughBuildWindow(state *newagenttools.ScheduleState) string { + if state == nil || len(state.Window.DayMapping) == 0 { + return "empty" + } + first := state.Window.DayMapping[0] + last := state.Window.DayMapping[len(state.Window.DayMapping)-1] + return fmt.Sprintf( + "days=%d first=W%dD%d last=W%dD%d", + len(state.Window.DayMapping), + first.Week, + first.DayOfWeek, + last.Week, + last.DayOfWeek, + ) +} + +// collectScopedTaskSamples 提供当前 state 中可用于匹配的 task_item 样本,便于排查 ID 对不上。 +func collectScopedTaskSamples(state *newagenttools.ScheduleState, taskClassIDs []int) []string { + if state == nil { + return nil + } + samples := make([]string, 0, roughBuildSampleLimit) + for i := range state.Tasks { + task := state.Tasks[i] + if task.Source != "task_item" { + continue + } + if len(taskClassIDs) > 0 && !newagenttools.IsTaskInRequestedClassScope(task, taskClassIDs) { + continue + } + samples = append(samples, fmt.Sprintf( + "source_id=%d task_class_id=%d status=%s name=%q", + task.SourceID, + task.TaskClassID, + task.Status, + task.Name, + )) + if len(samples) >= roughBuildSampleLimit { break } } + return samples } diff --git a/backend/newAgent/tools/SCHEDULE_TOOLS.md b/backend/newAgent/tools/SCHEDULE_TOOLS.md index 6f9c340..8346785 100644 --- a/backend/newAgent/tools/SCHEDULE_TOOLS.md +++ b/backend/newAgent/tools/SCHEDULE_TOOLS.md @@ -112,10 +112,10 @@ State 是工具层的操作对象,存在于内存中,不直接暴露给 LLM | `source_id` | int | 原表主键(ScheduleEvent.ID 或 TaskClassItem.ID),写库时用于反查 | | `name` | string | 任务名称,来自 ScheduleEvent.Name 或 TaskClassItem.Content | | `category` | string | 类别名,来自 TaskClass.Name(如"课程"、"学习"、"作业") | -| `status` | string | `"existing"`(已安排)| `"pending"`(待安排)| +| `status` | string | `"existing"`(已安排/已确定)| `"suggested"`(已预排/可优化)| `"pending"`(待安排)| | `locked` | bool | 是否锁定。推导规则:ScheduleEvent.Type="course" 且 CanBeEmbed=false 时为 true | | `slots` | array | 已安排任务的时段列表,每项含 day/slot_start/slot_end | -| `duration` | int | 待安排任务需要的连续时段数(仅 pending 任务) | +| `duration` | int | 待安排/已预排任务需要的连续时段数(pending / suggested 任务常见) | | `category_id` | int | 所属 TaskClass 的 ID(仅 source=task_item 时有值) | **嵌入任务相关字段(仅 can_embed=true 的任务):** @@ -291,7 +291,7 @@ DB 记录: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | category | string | 否 | 过滤类别(对应 TaskClass.Name,如"课程"、"学习") | -| status | string | 否 | existing / pending / all,默认 all | +| status | string | 否 | existing / suggested / pending / all,默认 all | **返回示例(待安排):** @@ -365,7 +365,7 @@ DB 记录: ### 5.1 place -将待安排任务放置到指定位置。 +将待安排任务预排到指定位置。 **入参:** @@ -408,7 +408,7 @@ DB 记录: ### 5.2 move -移动已有任务到新位置。 +移动已落位任务到新位置。 **入参:** @@ -449,7 +449,7 @@ DB 记录: ### 5.3 swap -交换两个已安排任务的位置。 +交换两个已落位任务的位置。 **入参:** @@ -515,7 +515,7 @@ DB 记录: ### 5.5 unplace -将已安排任务恢复为待安排状态。 +将已落位任务恢复为待安排状态。 **入参:** @@ -551,7 +551,8 @@ DB 记录: - place 新任务到锁定时段同样拒绝 ### 状态约束 -- pending 任务只能 place,不能 move / swap +- pending 任务只能 place,不能 move / swap / unplace +- suggested 任务可以 move / swap / unplace - existing 任务可以 move / swap / unplace - 状态不符时返回明确错误信息 @@ -574,7 +575,7 @@ DB 记录: - 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动 ### 数据库交互 -- State 初始化:从 Schedule + ScheduleEvent 加载 existing 任务,从 TaskClassItem 加载 pending 任务 +- State 初始化:从 Schedule + ScheduleEvent 加载 existing 任务,从 TaskClassItem 加载 pending 任务;粗排或工具预排成功后,任务转为 suggested - State 落库:Confirm 节点统一处理,将 state 变更转换为 Schedule/ScheduleEvent/TaskClassItem 的增删改 - 落库时使用 source + source_id 定位原记录,使用 day_mapping 将 day_index 转回 (week, day_of_week) - 落库时将 (slot_start, slot_end) 展开为逐条 Schedule 记录 diff --git a/backend/newAgent/tools/read_helpers.go b/backend/newAgent/tools/read_helpers.go index 4ca4102..9f50b35 100644 --- a/backend/newAgent/tools/read_helpers.go +++ b/backend/newAgent/tools/read_helpers.go @@ -54,14 +54,18 @@ func formatTaskLabelWithCategory(task ScheduleTask) string { // ==================== 占用计算辅助函数 ==================== -// getTasksOnDay 获取某天所有已安排任务的时段占用列表。 +// getTasksOnDay 获取某天所有“当前有落位”的任务占用列表。 +// +// 说明: +// 1. existing 与 suggested 都属于“有落位”; +// 2. 旧快照里若残留 pending+Slots,也会通过 Slots 被兼容识别; +// 3. 嵌入任务(有 EmbedHost 的)也会被返回,因为它们实际共享了该时段。 // 返回值按 slotStart 升序排列。 -// 注意:嵌入任务(有 EmbedHost 的)也会被返回,因为它们实际占用了时段。 func getTasksOnDay(state *ScheduleState, day int) []taskOnDay { var result []taskOnDay for i := range state.Tasks { t := &state.Tasks[i] - if t.Status != "existing" && !hasSlotOnDay(t, day) { + if !hasSlotOnDay(t, day) { continue } for _, slot := range t.Slots { diff --git a/backend/newAgent/tools/read_tools.go b/backend/newAgent/tools/read_tools.go index a43577e..d1e654d 100644 --- a/backend/newAgent/tools/read_tools.go +++ b/backend/newAgent/tools/read_tools.go @@ -31,17 +31,25 @@ func GetOverview(state *ScheduleState) string { } totalFree := totalSlots - totalOccupied - // 2. 统计待安排任务数。 + // 2. 统计任务状态分布。 + existingCount := 0 + suggestedCount := 0 pendingCount := 0 for i := range state.Tasks { - if state.Tasks[i].Status == "pending" { + task := state.Tasks[i] + switch { + case IsPendingTask(task): pendingCount++ + case IsSuggestedTask(task): + suggestedCount++ + case IsExistingTask(task): + existingCount++ } } var sb strings.Builder sb.WriteString(fmt.Sprintf("规划窗口共%d天,每天12个时段,总计%d个时段。\n", state.Window.TotalDays, totalSlots)) - sb.WriteString(fmt.Sprintf("当前已占用%d个,空闲%d个。待安排任务%d个。\n", totalOccupied, totalFree, pendingCount)) + sb.WriteString(fmt.Sprintf("当前已占用%d个,空闲%d个。已确定任务%d个,已预排任务%d个,待安排任务%d个。\n", totalOccupied, totalFree, existingCount, suggestedCount, pendingCount)) // 3. 逐天概况。 sb.WriteString("\n每日概况:\n") @@ -70,20 +78,33 @@ func GetOverview(state *ScheduleState) string { sb.WriteString(strings.Join(parts, ";") + "\n") } - // 5. 待安排任务汇总。 + // 5. 已预排任务汇总。 + if suggestedCount > 0 { + sb.WriteString("已预排:") + suggestedParts := make([]string, 0, suggestedCount) + for i := range state.Tasks { + t := &state.Tasks[i] + if IsSuggestedTask(*t) { + suggestedParts = append(suggestedParts, fmt.Sprintf("[%d]%s(%s)", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots))) + } + } + sb.WriteString(strings.Join(suggestedParts, " ") + "\n") + } + + // 6. 待安排任务汇总。 if pendingCount > 0 { sb.WriteString("待安排:") pendingParts := make([]string, 0, pendingCount) for i := range state.Tasks { t := &state.Tasks[i] - if t.Status == "pending" { + if IsPendingTask(*t) { pendingParts = append(pendingParts, fmt.Sprintf("[%d]%s(需%d时段)", t.StateID, t.Name, t.Duration)) } } sb.WriteString(strings.Join(pendingParts, " ") + "\n") } - // 6. 任务类约束(排课策略与限制)。 + // 7. 任务类约束(排课策略与限制)。 if len(state.TaskClasses) > 0 { sb.WriteString("\n任务类约束(排课时请遵守):\n") for _, tc := range state.TaskClasses { @@ -269,7 +290,7 @@ func FindFree(state *ScheduleState, duration int, day *int) string { // ListTasks 列出任务清单,可按类别和状态过滤。 // category 选填(nil 不过滤),status 选填(nil 默认 "all")。 -// 输出按状态分组:已安排在前,待安排在后。组内按 stateID 升序。 +// 输出按状态分组:已安排 -> 已预排 -> 待安排,组内按 stateID 升序。 func ListTasks(state *ScheduleState, category, status *string) string { // 1. 确定过滤状态。 statusFilter := "all" @@ -278,26 +299,36 @@ func ListTasks(state *ScheduleState, category, status *string) string { } // 2. 过滤 + 分组。 - var existingTasks, pendingTasks []ScheduleTask + var existingTasks, suggestedTasks, pendingTasks []ScheduleTask for i := range state.Tasks { t := state.Tasks[i] // 类别过滤。 if category != nil && t.Category != *category { continue } - // 状态过滤。 - if statusFilter != "all" && t.Status != statusFilter { - continue - } - if t.Status == "pending" { + + switch { + case IsPendingTask(t): + if statusFilter != "all" && statusFilter != "pending" { + continue + } pendingTasks = append(pendingTasks, t) - } else { + case IsSuggestedTask(t): + if statusFilter != "all" && statusFilter != "suggested" { + continue + } + suggestedTasks = append(suggestedTasks, t) + default: + if statusFilter != "all" && statusFilter != "existing" { + continue + } existingTasks = append(existingTasks, t) } } // 3. 按 stateID 排序。 sort.Slice(existingTasks, func(i, j int) bool { return existingTasks[i].StateID < existingTasks[j].StateID }) + sort.Slice(suggestedTasks, func(i, j int) bool { return suggestedTasks[i].StateID < suggestedTasks[j].StateID }) sort.Slice(pendingTasks, func(i, j int) bool { return pendingTasks[i].StateID < pendingTasks[j].StateID }) // 4. 纯待安排模式:只输出待安排任务。 @@ -305,20 +336,29 @@ func ListTasks(state *ScheduleState, category, status *string) string { return formatPendingList(pendingTasks) } - // 5. 纯已安排模式:只输出已安排任务。 + // 5. 纯已预排模式:只输出已预排任务。 + if statusFilter == "suggested" { + return formatSuggestedList(suggestedTasks) + } + + // 6. 纯已安排模式:只输出已安排任务。 if statusFilter == "existing" { return formatExistingList(existingTasks) } - // 6. 全部模式:统计 + 分组输出。 - total := len(existingTasks) + len(pendingTasks) + // 7. 全部模式:统计 + 分组输出。 + total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks) var sb strings.Builder - sb.WriteString(fmt.Sprintf("共%d个任务,已安排%d个,待安排%d个。\n", total, len(existingTasks), len(pendingTasks))) + sb.WriteString(fmt.Sprintf("共%d个任务,已安排%d个,已预排%d个,待安排%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks))) if len(existingTasks) > 0 { sb.WriteString("\n已安排:\n") sb.WriteString(formatExistingList(existingTasks)) } + if len(suggestedTasks) > 0 { + sb.WriteString("\n已预排:\n") + sb.WriteString(formatSuggestedList(suggestedTasks)) + } if len(pendingTasks) > 0 { sb.WriteString("\n待安排:\n") sb.WriteString(formatPendingList(pendingTasks)) @@ -341,8 +381,10 @@ func GetTaskInfo(state *ScheduleState, taskID int) string { // 1. 类别、状态、来源。 statusLabel := "已安排" - if task.Status == "pending" { + if IsPendingTask(*task) { statusLabel = "待安排" + } else if IsSuggestedTask(*task) { + statusLabel = "已预排" } else if task.Locked { statusLabel = "已安排(固定)" } @@ -362,9 +404,11 @@ func GetTaskInfo(state *ScheduleState, taskID int) string { } } - // 4. 待安排任务显示需要时段数。 - if task.Status == "pending" { + // 4. 任务时长信息。 + if IsPendingTask(*task) { sb.WriteString(fmt.Sprintf("需要时段:%d个连续时段\n", task.Duration)) + } else if IsSuggestedTask(*task) && task.Duration > 0 { + sb.WriteString(fmt.Sprintf("原始需求:%d个连续时段\n", task.Duration)) } // 5. 嵌入关系信息。 @@ -468,6 +512,19 @@ func formatExistingList(tasks []ScheduleTask) string { return sb.String() } +// formatSuggestedList 格式化已预排任务列表。 +// 格式如:[3]复习线代 — 已预排至 第2天第3-4节,类别:学习 +func formatSuggestedList(tasks []ScheduleTask) string { + var sb strings.Builder + if len(tasks) > 0 { + sb.WriteString(fmt.Sprintf("已预排任务共%d个:\n\n", len(tasks))) + } + for _, t := range tasks { + sb.WriteString(fmt.Sprintf("[%d]%s — 已预排至 %s,类别:%s\n", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots), t.Category)) + } + return sb.String() +} + // formatPendingList 格式化待安排任务列表。 // 格式如:[3]复习线代 — 需3个连续时段,类别:学习 func formatPendingList(tasks []ScheduleTask) string { diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go index fb16001..879ef36 100644 --- a/backend/newAgent/tools/registry.go +++ b/backend/newAgent/tools/registry.go @@ -135,8 +135,8 @@ func NewDefaultRegistry() *ToolRegistry { ) r.Register("list_tasks", - "列出任务清单,可按类别和状态过滤。category 选填,status 选填(默认 all)。", - `{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","pending"]}}}`, + "列出任务清单,可按类别和状态过滤。category 选填,status 选填(默认 all,支持 existing/suggested/pending)。", + `{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","suggested","pending"]}}}`, func(state *ScheduleState, args map[string]any) string { return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status")) }, @@ -156,7 +156,7 @@ func NewDefaultRegistry() *ToolRegistry { // --- 写工具 --- r.Register("place", - "将一个待安排任务放到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。", + "将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。", `{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`, func(state *ScheduleState, args map[string]any) string { taskID, ok := argsInt(args, "task_id") @@ -176,7 +176,7 @@ func NewDefaultRegistry() *ToolRegistry { ) r.Register("move", - "将一个已安排任务移动到新位置。task_id/new_day/new_slot_start 必填。", + "将一个已落位任务(existing 或 suggested)移动到新位置。task_id/new_day/new_slot_start 必填。", `{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`, func(state *ScheduleState, args map[string]any) string { taskID, ok := argsInt(args, "task_id") @@ -196,7 +196,7 @@ func NewDefaultRegistry() *ToolRegistry { ) r.Register("swap", - "交换两个已安排任务的位置。两个任务必须时长相同。task_a/task_b 必填。", + "交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。", `{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`, func(state *ScheduleState, args map[string]any) string { taskA, ok := argsInt(args, "task_a") @@ -224,7 +224,7 @@ func NewDefaultRegistry() *ToolRegistry { ) r.Register("unplace", - "将一个已安排任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。", + "将一个已落位任务移除,恢复为待安排状态。会自动清理嵌入关系。task_id 必填。", `{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`, func(state *ScheduleState, args map[string]any) string { taskID, ok := argsInt(args, "task_id") diff --git a/backend/newAgent/tools/state.go b/backend/newAgent/tools/state.go index c375bab..d6debb0 100644 --- a/backend/newAgent/tools/state.go +++ b/backend/newAgent/tools/state.go @@ -42,12 +42,12 @@ type ScheduleTask struct { SourceID int `json:"source_id"` // ScheduleEvent.ID or TaskClassItem.ID Name string `json:"name"` Category string `json:"category"` // e.g. "课程", "学习", "作业" - Status string `json:"status"` // "existing" | "pending" + Status string `json:"status"` // "existing" | "suggested" | "pending" Locked bool `json:"locked"` - // Existing task: compressed slot ranges. Pending task: nil until placed. + // Existing / suggested task: compressed slot ranges. Pending task: nil until placed. Slots []TaskSlot `json:"slots,omitempty"` - // Pending task: required consecutive slot count. + // Pending / suggested task: required consecutive slot count. Duration int `json:"duration,omitempty"` // source=task_item only: TaskClass.ID,用于反查任务类约束。 TaskClassID int `json:"task_class_id,omitempty"` diff --git a/backend/newAgent/tools/status.go b/backend/newAgent/tools/status.go new file mode 100644 index 0000000..78c1736 --- /dev/null +++ b/backend/newAgent/tools/status.go @@ -0,0 +1,118 @@ +package newagenttools + +import "slices" + +// 任务状态常量。 +// +// 说明: +// 1. existing 表示“数据库里已经存在的已安排事实”,例如课程表事件、已持久化任务块; +// 2. suggested 表示“当前轮内存态里的建议落位”,来源可能是粗排结果,也可能是用户确认后的工具预排; +// 3. pending 表示“仍未落位的真实待安排任务”。 +const ( + TaskStatusExisting = "existing" + TaskStatusSuggested = "suggested" + TaskStatusPending = "pending" +) + +// IsPendingTask 判断任务是否属于“真实待安排”状态。 +// +// 并行迁移说明: +// 1. 只有 pending 且没有 Slots,才视为真正未落位; +// 2. 旧快照里可能存在“pending 但已有 Slots”的粗排遗留形态,这类任务不应继续算作待安排; +// 3. 这样可以在不强制清洗旧快照的前提下,先把新旧语义统一到“pending=无落位”。 +func IsPendingTask(task ScheduleTask) bool { + return task.Status == TaskStatusPending && len(task.Slots) == 0 +} + +// IsSuggestedTask 判断任务是否属于“建议落位 / 可优化”状态。 +// +// 并行迁移说明: +// 1. 新语义使用显式 suggested 状态; +// 2. 兼容旧 rough_build 快照:pending + Slots 视为 suggested; +// 3. 兼容旧 place 快照:existing + source=task_item + Duration>0 + Slots 视为 suggested。 +func IsSuggestedTask(task ScheduleTask) bool { + if len(task.Slots) == 0 { + return false + } + if task.Status == TaskStatusSuggested { + return true + } + if task.Status == TaskStatusPending { + return true + } + if task.Status == TaskStatusExisting && task.Source == "task_item" && task.Duration > 0 { + return true + } + return false +} + +// IsExistingTask 判断任务是否属于“已确定事实层”。 +// +// 说明: +// 1. 这里会主动排除 suggested 兼容形态,避免旧快照里的 existing+Duration>0 被误当成已确定任务; +// 2. 这样 list_tasks / get_overview 才能稳定区分“事实层 existing”和“建议层 suggested”。 +func IsExistingTask(task ScheduleTask) bool { + return task.Status == TaskStatusExisting && !IsSuggestedTask(task) +} + +// IsPlacedTask 判断任务当前是否已经拥有可操作的落位。 +// +// 说明: +// 1. existing 和 suggested 都属于“已落位”; +// 2. pending 只有在并行迁移兼容形态(pending + Slots)下,才会被 IsSuggestedTask 吸收进来。 +func IsPlacedTask(task ScheduleTask) bool { + return IsExistingTask(task) || IsSuggestedTask(task) +} + +// IsTaskInRequestedClassScope 判断 task_item 是否属于“本轮请求涉及的任务类范围”。 +// +// 说明: +// 1. task_class_ids 为空时,视为不做范围裁剪,统一返回 true; +// 2. 仅 source=task_item 才有 task_class_id 语义,event 不参与该判断; +// 3. 迁移期若 task_item 缺失 TaskClassID,则在有显式 scope 时按“不在范围内”处理, +// 避免把域外 pending 误混进本轮粗排/微调。 +func IsTaskInRequestedClassScope(task ScheduleTask, taskClassIDs []int) bool { + if len(taskClassIDs) == 0 { + return true + } + if task.Source != "task_item" { + return false + } + return task.TaskClassID > 0 && slices.Contains(taskClassIDs, task.TaskClassID) +} + +// FilterScheduleStateForTaskClassScope 按“本轮请求的任务类范围”裁剪工具态里的域外 pending。 +// +// 步骤说明: +// 1. existing / suggested 一律保留,因为它们已经是事实层或建议层落位,会参与冲突判断; +// 2. 仅移除“域外真实 pending”,避免粗排校验和读工具把别的任务类误算进来; +// 3. TaskClasses 元数据也同步按 scope 裁剪,避免 prompt/工具读到无关约束; +// 4. 这里做就地裁剪,调用方无需再维护第二份 scoped state。 +func FilterScheduleStateForTaskClassScope(state *ScheduleState, taskClassIDs []int) { + if state == nil || len(taskClassIDs) == 0 { + return + } + + filteredTasks := make([]ScheduleTask, 0, len(state.Tasks)) + for _, task := range state.Tasks { + if !IsPendingTask(task) { + filteredTasks = append(filteredTasks, task) + continue + } + if IsTaskInRequestedClassScope(task, taskClassIDs) { + filteredTasks = append(filteredTasks, task) + } + } + state.Tasks = filteredTasks + + if len(state.TaskClasses) == 0 { + return + } + filteredMetas := make([]TaskClassMeta, 0, len(state.TaskClasses)) + for _, meta := range state.TaskClasses { + if slices.Contains(taskClassIDs, meta.ID) { + filteredMetas = append(filteredMetas, meta) + } + } + state.TaskClasses = filteredMetas +} diff --git a/backend/newAgent/tools/write_helpers.go b/backend/newAgent/tools/write_helpers.go index bebf74f..0b0141d 100644 --- a/backend/newAgent/tools/write_helpers.go +++ b/backend/newAgent/tools/write_helpers.go @@ -117,11 +117,15 @@ func taskDuration(task ScheduleTask) int { return total } -// countPending 统计当前 state 中待安排任务数量。 +// countPending 统计当前 state 中“真实待安排”任务数量。 +// +// 说明: +// 1. 这里只统计 pending 且无 Slots 的任务; +// 2. 旧快照里 pending+Slots 会被 suggested 兼容层吸收,不再算入待安排。 func countPending(state *ScheduleState) int { count := 0 for i := range state.Tasks { - if state.Tasks[i].Status == "pending" { + if IsPendingTask(state.Tasks[i]) { count++ } } diff --git a/backend/newAgent/tools/write_tools.go b/backend/newAgent/tools/write_tools.go index 770c43b..7da8ebf 100644 --- a/backend/newAgent/tools/write_tools.go +++ b/backend/newAgent/tools/write_tools.go @@ -20,8 +20,8 @@ type MoveRequest struct { // ==================== Place ==================== -// Place 将一个待安排任务放到指定位置。 -// taskID 必须是 pending 状态的任务。 +// Place 将一个待安排任务预排到指定位置。 +// taskID 必须是真实 pending(无 Slots)状态的任务。 // 如果目标位置有可嵌入宿主(can_embed=true 且未被嵌入),自动走嵌入逻辑。 func Place(state *ScheduleState, taskID, day, slotStart int) string { // 1. 查找任务。 @@ -31,7 +31,10 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string { } // 2. 校验状态。 - if task.Status != "pending" { + // 2.1 只有“真实 pending”才允许 place; + // 2.2 suggested / existing 都说明任务已经有落位,继续 place 会破坏当前方案语义; + // 2.3 旧快照里的 pending+Slots 也会被 IsPendingTask 排除,避免重复补排。 + if !IsPendingTask(*task) { return fmt.Sprintf("放置失败:[%d]%s 不是待安排任务,无法放置。", task.StateID, task.Name) } @@ -63,33 +66,33 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string { // 6. 执行变更。 if host != nil { - // 嵌入路径:设置双向嵌入关系。 + // 嵌入路径:设置双向嵌入关系,并把任务提升为 suggested。 guestID := task.StateID hostID := host.StateID task.EmbedHost = &hostID host.EmbeddedBy = &guestID task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}} - task.Status = "existing" + task.Status = TaskStatusSuggested - return fmt.Sprintf("已将 [%d]%s 嵌入到第%d天第%s(宿主:[%d]%s)。\n%s\n待安排任务剩余:%d个。", + return fmt.Sprintf("已将 [%d]%s 预排并嵌入到第%d天第%s(宿主:[%d]%s)。\n%s\n待安排任务剩余:%d个。", task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd), host.StateID, host.Name, formatDayOccupancy(state, day), countPending(state)) } - // 普通路径:直接放置。 + // 普通路径:直接放置,并标记为 suggested。 task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}} - task.Status = "existing" + task.Status = TaskStatusSuggested - return fmt.Sprintf("已将 [%d]%s 放到第%d天第%s。\n%s\n待安排任务剩余:%d个。", + return fmt.Sprintf("已将 [%d]%s 预排到第%d天第%s。\n%s\n待安排任务剩余:%d个。", task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd), formatDayOccupancy(state, day), countPending(state)) } // ==================== Move ==================== -// Move 将一个已安排任务移动到新位置。 -// taskID 必须是 existing 状态且非锁定。 +// Move 将一个已落位任务移动到新位置。 +// taskID 允许是 suggested / existing,但不能是真实 pending。 func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string { // 1. 查找任务。 task := state.TaskByStateID(taskID) @@ -98,7 +101,7 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string { } // 2. 校验状态。 - if task.Status == "pending" { + if IsPendingTask(*task) { return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name) } @@ -148,8 +151,8 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string { // ==================== Swap ==================== -// Swap 交换两个已安排任务的位置。 -// 两个任务都必须是 existing 状态、非锁定、总时长相同。 +// Swap 交换两个已落位任务的位置。 +// 两个任务都必须是 suggested / existing、非锁定、总时长相同。 func Swap(state *ScheduleState, taskAID, taskBID int) string { // 1. 查找两个任务。 taskA := state.TaskByStateID(taskAID) @@ -166,11 +169,11 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string { } // 2. 校验状态。 - if taskA.Status != "existing" { - return fmt.Sprintf("交换失败:[%d]%s 不是已安排任务。", taskA.StateID, taskA.Name) + if !IsPlacedTask(*taskA) { + return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskA.StateID, taskA.Name) } - if taskB.Status != "existing" { - return fmt.Sprintf("交换失败:[%d]%s 不是已安排任务。", taskB.StateID, taskB.Name) + if !IsPlacedTask(*taskB) { + return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskB.StateID, taskB.Name) } // 3. 校验锁定。 @@ -257,7 +260,7 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string { if task == nil { return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求)。", m.TaskID, i+1) } - if task.Status == "pending" { + if IsPendingTask(*task) { return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place(第%d条移动请求)。", task.StateID, task.Name, i+1) } @@ -327,8 +330,8 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string { // ==================== Unplace ==================== -// Unplace 将一个已安排任务移除,恢复为待安排状态。 -// taskID 必须是 existing 状态且非锁定。 +// Unplace 将一个已落位任务移除,恢复为待安排状态。 +// taskID 允许是 suggested / existing,但不能是真实 pending。 // 如果任务有嵌入关系,会自动清理双向指针。 func Unplace(state *ScheduleState, taskID int) string { // 1. 查找任务。 @@ -338,7 +341,7 @@ func Unplace(state *ScheduleState, taskID int) string { } // 2. 校验状态。 - if task.Status == "pending" { + if IsPendingTask(*task) { return fmt.Sprintf("移除失败:[%d]%s 已经是待安排状态。", task.StateID, task.Name) } @@ -372,14 +375,14 @@ func Unplace(state *ScheduleState, taskID int) string { } guest.EmbedHost = nil guest.Slots = nil - guest.Status = "pending" + guest.Status = TaskStatusPending } task.EmbeddedBy = nil } // 6. 执行变更。 task.Slots = nil - task.Status = "pending" + task.Status = TaskStatusPending // 7. 收集涉及的天。 affectedDays := collectAffectedDaysFromSlots(oldSlots) diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index ed161a1..331a956 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -495,19 +495,22 @@ func summarizeScheduleStateForPreviewDebug(state *newagenttools.ScheduleState) s } total := len(state.Tasks) - pendingNoSlot := 0 - pendingWithSlot := 0 + pendingTotal := 0 + suggestedTotal := 0 + existingTotal := 0 taskItemWithSlot := 0 eventWithSlot := 0 for i := range state.Tasks { t := &state.Tasks[i] hasSlot := len(t.Slots) > 0 - if t.Status == "pending" { - if hasSlot { - pendingWithSlot++ - } else { - pendingNoSlot++ - } + + switch { + case newagenttools.IsPendingTask(*t): + pendingTotal++ + case newagenttools.IsSuggestedTask(*t): + suggestedTotal++ + case newagenttools.IsExistingTask(*t): + existingTotal++ } if hasSlot { if t.Source == "task_item" { @@ -519,10 +522,11 @@ func summarizeScheduleStateForPreviewDebug(state *newagenttools.ScheduleState) s } } return fmt.Sprintf( - "tasks=%d pending_no_slot=%d pending_with_slot=%d task_item_with_slot=%d event_with_slot=%d", + "tasks=%d pending=%d suggested=%d existing=%d task_item_with_slot=%d event_with_slot=%d", total, - pendingNoSlot, - pendingWithSlot, + pendingTotal, + suggestedTotal, + existingTotal, taskItemWithSlot, eventWithSlot, )