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