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:
Losita
2026-04-07 23:58:00 +08:00
parent 07d307fe07
commit cdedd3c968
14 changed files with 599 additions and 174 deletions

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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
} }

View File

@@ -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 的循环依赖。

View File

@@ -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.Slotspending 任务预填位置) // 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_taskslist_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_idSourceID定位任务 // 1. 通过 task_item_idSourceID定位任务
// 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
} }

View File

@@ -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 记录

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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"`

View 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
}

View File

@@ -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++
} }
} }

View File

@@ -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)

View File

@@ -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,
) )