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

@@ -112,10 +112,10 @@ State 是工具层的操作对象,存在于内存中,不直接暴露给 LLM
| `source_id` | int | 原表主键ScheduleEvent.ID 或 TaskClassItem.ID写库时用于反查 |
| `name` | string | 任务名称,来自 ScheduleEvent.Name 或 TaskClassItem.Content |
| `category` | string | 类别名,来自 TaskClass.Name如"课程"、"学习"、"作业" |
| `status` | string | `"existing"`(已安排)| `"pending"`(待安排)|
| `status` | string | `"existing"`(已安排/已确定)| `"suggested"`(已预排/可优化| `"pending"`(待安排)|
| `locked` | bool | 是否锁定。推导规则ScheduleEvent.Type="course" 且 CanBeEmbed=false 时为 true |
| `slots` | array | 已安排任务的时段列表,每项含 day/slot_start/slot_end |
| `duration` | int | 待安排任务需要的连续时段数(pending 任务 |
| `duration` | int | 待安排/已预排任务需要的连续时段数pending / suggested 任务常见 |
| `category_id` | int | 所属 TaskClass 的 ID仅 source=task_item 时有值) |
**嵌入任务相关字段(仅 can_embed=true 的任务):**
@@ -291,7 +291,7 @@ DB 记录:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| category | string | 否 | 过滤类别(对应 TaskClass.Name如"课程"、"学习" |
| status | string | 否 | existing / pending / all默认 all |
| status | string | 否 | existing / suggested / pending / all默认 all |
**返回示例(待安排):**
@@ -365,7 +365,7 @@ DB 记录:
### 5.1 place
将待安排任务放置到指定位置。
将待安排任务预排到指定位置。
**入参:**
@@ -408,7 +408,7 @@ DB 记录:
### 5.2 move
移动已任务到新位置。
移动已落位任务到新位置。
**入参:**
@@ -449,7 +449,7 @@ DB 记录:
### 5.3 swap
交换两个已安排任务的位置。
交换两个已落位任务的位置。
**入参:**
@@ -515,7 +515,7 @@ DB 记录:
### 5.5 unplace
将已安排任务恢复为待安排状态。
将已落位任务恢复为待安排状态。
**入参:**
@@ -551,7 +551,8 @@ DB 记录:
- place 新任务到锁定时段同样拒绝
### 状态约束
- pending 任务只能 place不能 move / swap
- pending 任务只能 place不能 move / swap / unplace
- suggested 任务可以 move / swap / unplace
- existing 任务可以 move / swap / unplace
- 状态不符时返回明确错误信息
@@ -574,7 +575,7 @@ DB 记录:
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动
### 数据库交互
- State 初始化:从 Schedule + ScheduleEvent 加载 existing 任务,从 TaskClassItem 加载 pending 任务
- State 初始化:从 Schedule + ScheduleEvent 加载 existing 任务,从 TaskClassItem 加载 pending 任务;粗排或工具预排成功后,任务转为 suggested
- State 落库Confirm 节点统一处理,将 state 变更转换为 Schedule/ScheduleEvent/TaskClassItem 的增删改
- 落库时使用 source + source_id 定位原记录,使用 day_mapping 将 day_index 转回 (week, day_of_week)
- 落库时将 (slot_start, slot_end) 展开为逐条 Schedule 记录

View File

@@ -54,14 +54,18 @@ func formatTaskLabelWithCategory(task ScheduleTask) string {
// ==================== 占用计算辅助函数 ====================
// getTasksOnDay 获取某天所有已安排任务的时段占用列表。
// getTasksOnDay 获取某天所有“当前有落位”的任务占用列表。
//
// 说明:
// 1. existing 与 suggested 都属于“有落位”;
// 2. 旧快照里若残留 pending+Slots也会通过 Slots 被兼容识别;
// 3. 嵌入任务(有 EmbedHost 的)也会被返回,因为它们实际共享了该时段。
// 返回值按 slotStart 升序排列。
// 注意:嵌入任务(有 EmbedHost 的)也会被返回,因为它们实际占用了时段。
func getTasksOnDay(state *ScheduleState, day int) []taskOnDay {
var result []taskOnDay
for i := range state.Tasks {
t := &state.Tasks[i]
if t.Status != "existing" && !hasSlotOnDay(t, day) {
if !hasSlotOnDay(t, day) {
continue
}
for _, slot := range t.Slots {

View File

@@ -31,17 +31,25 @@ func GetOverview(state *ScheduleState) string {
}
totalFree := totalSlots - totalOccupied
// 2. 统计待安排任务数
// 2. 统计任务状态分布
existingCount := 0
suggestedCount := 0
pendingCount := 0
for i := range state.Tasks {
if state.Tasks[i].Status == "pending" {
task := state.Tasks[i]
switch {
case IsPendingTask(task):
pendingCount++
case IsSuggestedTask(task):
suggestedCount++
case IsExistingTask(task):
existingCount++
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("规划窗口共%d天每天12个时段总计%d个时段。\n", state.Window.TotalDays, totalSlots))
sb.WriteString(fmt.Sprintf("当前已占用%d个空闲%d个。待安排任务%d个。\n", totalOccupied, totalFree, pendingCount))
sb.WriteString(fmt.Sprintf("当前已占用%d个空闲%d个。已确定任务%d个已预排任务%d个待安排任务%d个。\n", totalOccupied, totalFree, existingCount, suggestedCount, pendingCount))
// 3. 逐天概况。
sb.WriteString("\n每日概况\n")
@@ -70,20 +78,33 @@ func GetOverview(state *ScheduleState) string {
sb.WriteString(strings.Join(parts, "") + "\n")
}
// 5. 待安排任务汇总。
// 5. 已预排任务汇总。
if suggestedCount > 0 {
sb.WriteString("已预排:")
suggestedParts := make([]string, 0, suggestedCount)
for i := range state.Tasks {
t := &state.Tasks[i]
if IsSuggestedTask(*t) {
suggestedParts = append(suggestedParts, fmt.Sprintf("[%d]%s(%s)", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots)))
}
}
sb.WriteString(strings.Join(suggestedParts, " ") + "\n")
}
// 6. 待安排任务汇总。
if pendingCount > 0 {
sb.WriteString("待安排:")
pendingParts := make([]string, 0, pendingCount)
for i := range state.Tasks {
t := &state.Tasks[i]
if t.Status == "pending" {
if IsPendingTask(*t) {
pendingParts = append(pendingParts, fmt.Sprintf("[%d]%s(需%d时段)", t.StateID, t.Name, t.Duration))
}
}
sb.WriteString(strings.Join(pendingParts, " ") + "\n")
}
// 6. 任务类约束(排课策略与限制)。
// 7. 任务类约束(排课策略与限制)。
if len(state.TaskClasses) > 0 {
sb.WriteString("\n任务类约束排课时请遵守\n")
for _, tc := range state.TaskClasses {
@@ -269,7 +290,7 @@ func FindFree(state *ScheduleState, duration int, day *int) string {
// ListTasks 列出任务清单,可按类别和状态过滤。
// category 选填nil 不过滤status 选填nil 默认 "all")。
// 输出按状态分组:已安排在前,待安排在后。组内按 stateID 升序。
// 输出按状态分组:已安排 -> 已预排 -> 待安排,组内按 stateID 升序。
func ListTasks(state *ScheduleState, category, status *string) string {
// 1. 确定过滤状态。
statusFilter := "all"
@@ -278,26 +299,36 @@ func ListTasks(state *ScheduleState, category, status *string) string {
}
// 2. 过滤 + 分组。
var existingTasks, pendingTasks []ScheduleTask
var existingTasks, suggestedTasks, pendingTasks []ScheduleTask
for i := range state.Tasks {
t := state.Tasks[i]
// 类别过滤。
if category != nil && t.Category != *category {
continue
}
// 状态过滤。
if statusFilter != "all" && t.Status != statusFilter {
continue
}
if t.Status == "pending" {
switch {
case IsPendingTask(t):
if statusFilter != "all" && statusFilter != "pending" {
continue
}
pendingTasks = append(pendingTasks, t)
} else {
case IsSuggestedTask(t):
if statusFilter != "all" && statusFilter != "suggested" {
continue
}
suggestedTasks = append(suggestedTasks, t)
default:
if statusFilter != "all" && statusFilter != "existing" {
continue
}
existingTasks = append(existingTasks, t)
}
}
// 3. 按 stateID 排序。
sort.Slice(existingTasks, func(i, j int) bool { return existingTasks[i].StateID < existingTasks[j].StateID })
sort.Slice(suggestedTasks, func(i, j int) bool { return suggestedTasks[i].StateID < suggestedTasks[j].StateID })
sort.Slice(pendingTasks, func(i, j int) bool { return pendingTasks[i].StateID < pendingTasks[j].StateID })
// 4. 纯待安排模式:只输出待安排任务。
@@ -305,20 +336,29 @@ func ListTasks(state *ScheduleState, category, status *string) string {
return formatPendingList(pendingTasks)
}
// 5. 纯已排模式:只输出已排任务。
// 5. 纯已排模式:只输出已排任务。
if statusFilter == "suggested" {
return formatSuggestedList(suggestedTasks)
}
// 6. 纯已安排模式:只输出已安排任务。
if statusFilter == "existing" {
return formatExistingList(existingTasks)
}
// 6. 全部模式:统计 + 分组输出。
total := len(existingTasks) + len(pendingTasks)
// 7. 全部模式:统计 + 分组输出。
total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("共%d个任务已安排%d个待安排%d个。\n", total, len(existingTasks), len(pendingTasks)))
sb.WriteString(fmt.Sprintf("共%d个任务已安排%d个已预排%d个待安排%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks)))
if len(existingTasks) > 0 {
sb.WriteString("\n已安排\n")
sb.WriteString(formatExistingList(existingTasks))
}
if len(suggestedTasks) > 0 {
sb.WriteString("\n已预排\n")
sb.WriteString(formatSuggestedList(suggestedTasks))
}
if len(pendingTasks) > 0 {
sb.WriteString("\n待安排\n")
sb.WriteString(formatPendingList(pendingTasks))
@@ -341,8 +381,10 @@ func GetTaskInfo(state *ScheduleState, taskID int) string {
// 1. 类别、状态、来源。
statusLabel := "已安排"
if task.Status == "pending" {
if IsPendingTask(*task) {
statusLabel = "待安排"
} else if IsSuggestedTask(*task) {
statusLabel = "已预排"
} else if task.Locked {
statusLabel = "已安排(固定)"
}
@@ -362,9 +404,11 @@ func GetTaskInfo(state *ScheduleState, taskID int) string {
}
}
// 4. 待安排任务显示需要时段数
if task.Status == "pending" {
// 4. 任务时长信息
if IsPendingTask(*task) {
sb.WriteString(fmt.Sprintf("需要时段:%d个连续时段\n", task.Duration))
} else if IsSuggestedTask(*task) && task.Duration > 0 {
sb.WriteString(fmt.Sprintf("原始需求:%d个连续时段\n", task.Duration))
}
// 5. 嵌入关系信息。
@@ -468,6 +512,19 @@ func formatExistingList(tasks []ScheduleTask) string {
return sb.String()
}
// formatSuggestedList 格式化已预排任务列表。
// 格式如:[3]复习线代 — 已预排至 第2天第3-4节类别学习
func formatSuggestedList(tasks []ScheduleTask) string {
var sb strings.Builder
if len(tasks) > 0 {
sb.WriteString(fmt.Sprintf("已预排任务共%d个\n\n", len(tasks)))
}
for _, t := range tasks {
sb.WriteString(fmt.Sprintf("[%d]%s — 已预排至 %s类别%s\n", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots), t.Category))
}
return sb.String()
}
// formatPendingList 格式化待安排任务列表。
// 格式如:[3]复习线代 — 需3个连续时段类别学习
func formatPendingList(tasks []ScheduleTask) string {

View File

@@ -135,8 +135,8 @@ func NewDefaultRegistry() *ToolRegistry {
)
r.Register("list_tasks",
"列出任务清单可按类别和状态过滤。category 选填status 选填(默认 all。",
`{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","pending"]}}}`,
"列出任务清单可按类别和状态过滤。category 选填status 选填(默认 all,支持 existing/suggested/pending)。",
`{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","suggested","pending"]}}}`,
func(state *ScheduleState, args map[string]any) string {
return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status"))
},
@@ -156,7 +156,7 @@ func NewDefaultRegistry() *ToolRegistry {
// --- 写工具 ---
r.Register("place",
"将一个待安排任务到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。",
"将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。",
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")
@@ -176,7 +176,7 @@ func NewDefaultRegistry() *ToolRegistry {
)
r.Register("move",
"将一个已安排任务移动到新位置。task_id/new_day/new_slot_start 必填。",
"将一个已落位任务existing 或 suggested移动到新位置。task_id/new_day/new_slot_start 必填。",
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")
@@ -196,7 +196,7 @@ func NewDefaultRegistry() *ToolRegistry {
)
r.Register("swap",
"交换两个已安排任务的位置。两个任务必须时长相同。task_a/task_b 必填。",
"交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。",
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskA, ok := argsInt(args, "task_a")
@@ -224,7 +224,7 @@ func NewDefaultRegistry() *ToolRegistry {
)
r.Register("unplace",
"将一个已安排任务移除恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
"将一个已落位任务移除恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")

View File

@@ -42,12 +42,12 @@ type ScheduleTask struct {
SourceID int `json:"source_id"` // ScheduleEvent.ID or TaskClassItem.ID
Name string `json:"name"`
Category string `json:"category"` // e.g. "课程", "学习", "作业"
Status string `json:"status"` // "existing" | "pending"
Status string `json:"status"` // "existing" | "suggested" | "pending"
Locked bool `json:"locked"`
// Existing task: compressed slot ranges. Pending task: nil until placed.
// Existing / suggested task: compressed slot ranges. Pending task: nil until placed.
Slots []TaskSlot `json:"slots,omitempty"`
// Pending task: required consecutive slot count.
// Pending / suggested task: required consecutive slot count.
Duration int `json:"duration,omitempty"`
// source=task_item only: TaskClass.ID用于反查任务类约束。
TaskClassID int `json:"task_class_id,omitempty"`

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
}
// countPending 统计当前 state 中待安排任务数量。
// countPending 统计当前 state 中“真实待安排任务数量。
//
// 说明:
// 1. 这里只统计 pending 且无 Slots 的任务;
// 2. 旧快照里 pending+Slots 会被 suggested 兼容层吸收,不再算入待安排。
func countPending(state *ScheduleState) int {
count := 0
for i := range state.Tasks {
if state.Tasks[i].Status == "pending" {
if IsPendingTask(state.Tasks[i]) {
count++
}
}

View File

@@ -20,8 +20,8 @@ type MoveRequest struct {
// ==================== Place ====================
// Place 将一个待安排任务到指定位置。
// taskID 必须是 pending 状态的任务。
// Place 将一个待安排任务预排到指定位置。
// taskID 必须是真实 pending(无 Slots状态的任务。
// 如果目标位置有可嵌入宿主can_embed=true 且未被嵌入),自动走嵌入逻辑。
func Place(state *ScheduleState, taskID, day, slotStart int) string {
// 1. 查找任务。
@@ -31,7 +31,10 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
}
// 2. 校验状态。
if task.Status != "pending" {
// 2.1 只有“真实 pending”才允许 place
// 2.2 suggested / existing 都说明任务已经有落位,继续 place 会破坏当前方案语义;
// 2.3 旧快照里的 pending+Slots 也会被 IsPendingTask 排除,避免重复补排。
if !IsPendingTask(*task) {
return fmt.Sprintf("放置失败:[%d]%s 不是待安排任务,无法放置。", task.StateID, task.Name)
}
@@ -63,33 +66,33 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
// 6. 执行变更。
if host != nil {
// 嵌入路径:设置双向嵌入关系。
// 嵌入路径:设置双向嵌入关系,并把任务提升为 suggested
guestID := task.StateID
hostID := host.StateID
task.EmbedHost = &hostID
host.EmbeddedBy = &guestID
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
task.Status = "existing"
task.Status = TaskStatusSuggested
return fmt.Sprintf("已将 [%d]%s 嵌入到第%d天第%s宿主[%d]%s。\n%s\n待安排任务剩余%d个。",
return fmt.Sprintf("已将 [%d]%s 预排并嵌入到第%d天第%s宿主[%d]%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd),
host.StateID, host.Name,
formatDayOccupancy(state, day), countPending(state))
}
// 普通路径:直接放置。
// 普通路径:直接放置,并标记为 suggested
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
task.Status = "existing"
task.Status = TaskStatusSuggested
return fmt.Sprintf("已将 [%d]%s 到第%d天第%s。\n%s\n待安排任务剩余%d个。",
return fmt.Sprintf("已将 [%d]%s 预排到第%d天第%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd),
formatDayOccupancy(state, day), countPending(state))
}
// ==================== Move ====================
// Move 将一个已安排任务移动到新位置。
// taskID 必须是 existing 状态且非锁定
// Move 将一个已落位任务移动到新位置。
// taskID 允许是 suggested / existing但不能是真实 pending
func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
// 1. 查找任务。
task := state.TaskByStateID(taskID)
@@ -98,7 +101,7 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
}
// 2. 校验状态。
if task.Status == "pending" {
if IsPendingTask(*task) {
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
}
@@ -148,8 +151,8 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
// ==================== Swap ====================
// Swap 交换两个已安排任务的位置。
// 两个任务都必须是 existing 状态、非锁定、总时长相同。
// Swap 交换两个已落位任务的位置。
// 两个任务都必须是 suggested / existing、非锁定、总时长相同。
func Swap(state *ScheduleState, taskAID, taskBID int) string {
// 1. 查找两个任务。
taskA := state.TaskByStateID(taskAID)
@@ -166,11 +169,11 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
}
// 2. 校验状态。
if taskA.Status != "existing" {
return fmt.Sprintf("交换失败:[%d]%s 不是已安排任务。", taskA.StateID, taskA.Name)
if !IsPlacedTask(*taskA) {
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskA.StateID, taskA.Name)
}
if taskB.Status != "existing" {
return fmt.Sprintf("交换失败:[%d]%s 不是已安排任务。", taskB.StateID, taskB.Name)
if !IsPlacedTask(*taskB) {
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskB.StateID, taskB.Name)
}
// 3. 校验锁定。
@@ -257,7 +260,7 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
if task == nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求。", m.TaskID, i+1)
}
if task.Status == "pending" {
if IsPendingTask(*task) {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place第%d条移动请求。",
task.StateID, task.Name, i+1)
}
@@ -327,8 +330,8 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
// ==================== Unplace ====================
// Unplace 将一个已安排任务移除,恢复为待安排状态。
// taskID 必须是 existing 状态且非锁定
// Unplace 将一个已落位任务移除,恢复为待安排状态。
// taskID 允许是 suggested / existing但不能是真实 pending
// 如果任务有嵌入关系,会自动清理双向指针。
func Unplace(state *ScheduleState, taskID int) string {
// 1. 查找任务。
@@ -338,7 +341,7 @@ func Unplace(state *ScheduleState, taskID int) string {
}
// 2. 校验状态。
if task.Status == "pending" {
if IsPendingTask(*task) {
return fmt.Sprintf("移除失败:[%d]%s 已经是待安排状态。", task.StateID, task.Name)
}
@@ -372,14 +375,14 @@ func Unplace(state *ScheduleState, taskID int) string {
}
guest.EmbedHost = nil
guest.Slots = nil
guest.Status = "pending"
guest.Status = TaskStatusPending
}
task.EmbeddedBy = nil
}
// 6. 执行变更。
task.Slots = nil
task.Status = "pending"
task.Status = TaskStatusPending
// 7. 收集涉及的天。
affectedDays := collectAffectedDaysFromSlots(oldSlots)