Version: 0.9.9.dev.260408

后端:
1. 粗排后分流与顺序守卫落地,支持“无明确微调偏好时粗排后直接收口”,并新增 allow_reorder / needs_refine_after_rough_build 语义,打通 chat→rough_build→execute/order_guard→deliver 路由。
2. execute 工具执行链路修复:清理乱码坏块与重复分支;新增 min_context_switch 未授权拦截;补齐 suggested 顺序基线初始化与顺序守卫联动。
3. 新增复合写工具 min_context_switch(减少上下文切换)并接入注册、参数解析、写工具白名单、提示词与文档;仅在用户明确允许打乱顺序时可用。
4. 工具口径升级:find_first_free 支持 day/day_start/day_end 范围参数并统一文案;移除 find_free 兼容别名;读写工具输出统一到“第N天(星期X)”格式。
5. prompt 同步升级:chat/execute/execute_context 增加粗排后是否继续微调、顺序授权、min_context_switch 使用边界与返回示例约束。
6. handoff 文档重命名并重写下班交接重点:下一步聚焦“工具收敛能力研究 + 运行态必要参数重置(不丢运行态)”。
7. 同步更新调试日志文件。
前端:无
仓库:无
This commit is contained in:
Losita
2026-04-08 23:55:09 +08:00
parent 4195e65cba
commit 21b864390b
21 changed files with 3546 additions and 1009 deletions

View File

@@ -255,15 +255,14 @@ DB 记录:
按天顺序查找“首个可用位”(先纯空位,再可嵌入位),并返回该日详细信息。
兼容说明:
- `find_free` 仍保留为兼容别名,行为与 `find_first_free` 完全一致。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| duration | int | 是 | 需要的连续时段数 |
| day | int | 否 | 限定某天,不传则搜索全部天 |
| day | int | 否 | 限定某天;与 `day_start/day_end` 互斥 |
| day_start | int | 否 | 搜索起始天(闭区间) |
| day_end | int | 否 | 搜索结束天(闭区间) |
**返回示例:**
@@ -543,6 +542,42 @@ DB 记录:
---
### 5.6 min_context_switch
在给定任务集合内重排 suggested 任务,尽量把同类任务排成连续块,以减少上下文切换。
使用约束:
- 仅在用户明确说明“允许打乱顺序”时调用。
- 仅支持 suggested 且已落位任务。
- 工具只在传入集合内部重排,不会主动改动集合外任务。
**入参:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| task_ids | array[int] | 是 | 参与重排的任务 ID 列表(至少 2 个) |
| task_id | int | 否 | 兼容单值参数,不建议新调用使用 |
**成功返回:**
```
最少上下文切换重排完成:共处理 6 个任务,上下文切换次数 5 -> 2。
本次调整:
[35]概率第一章第3天(星期3)第1-2节 -> 第2天(星期2)第5-6节
[41]概率第二章第4天(星期4)第1-2节 -> 第3天(星期3)第1-2节
第2天当前占用...
第3天当前占用...
第4天当前占用...
```
**失败返回(未授权顺序重排时应由上层拦截):**
```
已拒绝执行 min_context_switch当前未授权打乱顺序。如需使用该工具请先由用户明确说明“允许打乱顺序”。
```
---
## 6. 公共规则
### 冲突检测
@@ -556,8 +591,8 @@ DB 记录:
### 状态约束
- pending 任务只能 place不能 move / swap / unplace
- suggested 任务可以 move / swap / unplace
- existing 任务不能 move / batch_move仅作已安排事实层
- suggested 任务可以 move / swap / unplace / min_context_switch
- existing 任务不能 move / batch_move / min_context_switch(仅作已安排事实层)
- 状态不符时返回明确错误信息
### 返回格式
@@ -574,7 +609,7 @@ DB 记录:
### 嵌入任务规则
- `can_embed=true` 的任务(水课)允许其他任务嵌入到同一时段
- 嵌入任务占位时不触发冲突检测(与宿主共存)
- `find_first_free` 返回首个命中位,并附当日详细负载`find_free` 为兼容别名
- `find_first_free` 返回首个命中位,并附当日详细负载
- `place` 到可嵌入时段时,若已有宿主任务,自动标记 embed_host 关系
- 嵌入任务的 locked 继承宿主:宿主不可移动时,嵌入任务也不可单独移动

View File

@@ -49,6 +49,44 @@ func argsStringPtr(args map[string]any, key string) *string {
return &v
}
// argsIntSlice 从 map 中提取 int 数组,支持 []any / []int / []float64。
func argsIntSlice(args map[string]any, key string) ([]int, bool) {
v, ok := args[key]
if !ok {
return nil, false
}
switch arr := v.(type) {
case []int:
if len(arr) == 0 {
return []int{}, true
}
result := make([]int, len(arr))
copy(result, arr)
return result, true
case []float64:
result := make([]int, 0, len(arr))
for _, item := range arr {
result = append(result, int(item))
}
return result, true
case []any:
result := make([]int, 0, len(arr))
for _, item := range arr {
switch n := item.(type) {
case float64:
result = append(result, int(n))
case int:
result = append(result, n)
default:
return nil, false
}
}
return result, true
default:
return nil, false
}
}
// argsMoveList 从 map 中提取 batch_move 的 moves 数组。
func argsMoveList(args map[string]any) ([]MoveRequest, error) {
v, ok := args["moves"]

View File

@@ -0,0 +1,458 @@
package newagenttools
import (
"fmt"
"sort"
"strings"
)
type minContextSnapshot struct {
StateID int
Name string
ContextTag string
Slot TaskSlot
}
type minContextPlanTask struct {
StateID int
Name string
ContextTag string
GroupingKey string
OriginRank int
Span int
}
type minContextPlanGroup struct {
Key string
MinRank int
Tasks []minContextPlanTask
}
// MinContextSwitch 在给定任务集合内重排 suggested 任务,减少上下文切换次数。
//
// 职责边界:
// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排;
// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务;
// 3. 采用原子提交:任一校验失败则整体不生效。
//
// 并行迁移说明:
// 1. 这里没有直接复用 backend/logic 的同名规划器;
// 2. 原因是 logic 包依赖链会回流到 newAgent/tools直接引用会产生 import cycle
// 3. 因此在 tools 层内置一份最小可用的确定性规划逻辑,先保证线上可用,再在后续结构迁移时抽公共层。
func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
if state == nil {
return "减少上下文切换失败:日程状态为空。"
}
normalizedIDs := uniquePositiveInts(taskIDs)
if len(normalizedIDs) < 2 {
return "减少上下文切换失败task_ids 至少需要 2 个有效任务 ID。"
}
// 1. 构建规划输入并做前置校验。
plannerTasks := make([]minContextPlanTask, 0, len(normalizedIDs))
plannerSlots := make([]TaskSlot, 0, len(normalizedIDs))
beforeByID := make(map[int]minContextSnapshot, len(normalizedIDs))
excludeIDs := make([]int, 0, len(normalizedIDs))
for rank, taskID := range normalizedIDs {
task := state.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("减少上下文切换失败任务ID %d 不存在。", taskID)
}
if !IsSuggestedTask(*task) {
return fmt.Sprintf("减少上下文切换失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具。", task.StateID, task.Name)
}
if err := checkLocked(*task); err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
if len(task.Slots) != 1 {
return fmt.Sprintf("减少上下文切换失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态。", task.StateID, task.Name, len(task.Slots))
}
slot := task.Slots[0]
if err := validateDay(state, slot.Day); err != nil {
return fmt.Sprintf("减少上下文切换失败:[%d]%s 的时段非法:%s。", task.StateID, task.Name, err.Error())
}
if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
return fmt.Sprintf("减少上下文切换失败:[%d]%s 的节次非法:%s。", task.StateID, task.Name, err.Error())
}
contextTag := normalizeMinContextTag(*task)
beforeByID[task.StateID] = minContextSnapshot{
StateID: task.StateID,
Name: task.Name,
ContextTag: contextTag,
Slot: slot,
}
excludeIDs = append(excludeIDs, task.StateID)
plannerTasks = append(plannerTasks, minContextPlanTask{
StateID: task.StateID,
Name: strings.TrimSpace(task.Name),
ContextTag: contextTag,
OriginRank: rank + 1,
Span: slot.SlotEnd - slot.SlotStart + 1,
})
plannerSlots = append(plannerSlots, slot)
}
plannedSlots, err := planMinContextAssignments(plannerTasks, plannerSlots)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
for taskID, before := range beforeByID {
targetSlot, ok := plannedSlots[taskID]
if !ok {
return "减少上下文切换失败:规划结果不完整。"
}
if err := validateDay(state, targetSlot.Day); err != nil {
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
}
if err := validateSlotRange(targetSlot.SlotStart, targetSlot.SlotEnd); err != nil {
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
}
if conflict := findConflict(state, targetSlot.Day, targetSlot.SlotStart, targetSlot.SlotEnd, excludeIDs...); conflict != nil {
return fmt.Sprintf(
"减少上下文切换失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
before.StateID,
before.Name,
formatDaySlotLabel(state, targetSlot.Day, targetSlot.SlotStart, targetSlot.SlotEnd),
conflict.StateID,
conflict.Name,
)
}
afterByID[before.StateID] = minContextSnapshot{
StateID: before.StateID,
Name: before.Name,
ContextTag: before.ContextTag,
Slot: targetSlot,
}
}
// 2. 全量通过后再原子提交,避免中间态污染。
clone := state.Clone()
for taskID, after := range afterByID {
task := clone.TaskByStateID(taskID)
if task == nil {
return fmt.Sprintf("减少上下文切换失败任务ID %d 在提交阶段不存在。", taskID)
}
task.Slots = []TaskSlot{after.Slot}
}
state.Tasks = clone.Tasks
beforeOrdered := sortMinContextSnapshots(beforeByID)
afterOrdered := sortMinContextSnapshots(afterByID)
beforeSwitches := countMinContextSwitches(beforeOrdered)
afterSwitches := countMinContextSwitches(afterOrdered)
changedLines := make([]string, 0, len(beforeOrdered))
affectedDays := make(map[int]bool, len(beforeOrdered)*2)
for _, before := range beforeOrdered {
after := afterByID[before.StateID]
if sameTaskSlot(before.Slot, after.Slot) {
continue
}
changedLines = append(changedLines, fmt.Sprintf(
" [%d]%s%s -> %s",
before.StateID,
before.Name,
formatDaySlotLabel(state, before.Slot.Day, before.Slot.SlotStart, before.Slot.SlotEnd),
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
))
affectedDays[before.Slot.Day] = true
affectedDays[after.Slot.Day] = true
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf(
"最少上下文切换重排完成:共处理 %d 个任务,上下文切换次数 %d -> %d。\n",
len(beforeByID), beforeSwitches, afterSwitches,
))
if len(changedLines) == 0 {
sb.WriteString("当前任务顺序已是较优结果,无需调整。")
return sb.String()
}
sb.WriteString("本次调整:\n")
for _, line := range changedLines {
sb.WriteString(line + "\n")
}
for _, day := range sortedKeys(affectedDays) {
sb.WriteString(formatDayOccupancy(state, day) + "\n")
}
return strings.TrimSpace(sb.String())
}
func parseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) {
if ids, ok := argsIntSlice(args, "task_ids"); ok && len(ids) > 0 {
return ids, nil
}
if id, ok := argsInt(args, "task_id"); ok {
return []int{id}, nil
}
return nil, fmt.Errorf("缺少必填参数 task_ids兼容单值 task_id")
}
func planMinContextAssignments(tasks []minContextPlanTask, slots []TaskSlot) (map[int]TaskSlot, error) {
if len(tasks) == 0 {
return nil, fmt.Errorf("任务列表为空")
}
if len(slots) == 0 {
return nil, fmt.Errorf("可用坑位为空")
}
if len(slots) < len(tasks) {
return nil, fmt.Errorf("可用坑位不足tasks=%d, slots=%d", len(tasks), len(slots))
}
sort.SliceStable(tasks, func(i, j int) bool {
if tasks[i].OriginRank != tasks[j].OriginRank {
return tasks[i].OriginRank < tasks[j].OriginRank
}
return tasks[i].StateID < tasks[j].StateID
})
for i := range tasks {
tasks[i].GroupingKey = normalizeMinContextGroupingKey(tasks[i].ContextTag)
}
applyMinContextNameFallback(tasks)
groupMap := make(map[string]*minContextPlanGroup, len(tasks))
groupOrder := make([]string, 0, len(tasks))
for _, task := range tasks {
group, exists := groupMap[task.GroupingKey]
if !exists {
group = &minContextPlanGroup{
Key: task.GroupingKey,
MinRank: task.OriginRank,
}
groupMap[task.GroupingKey] = group
groupOrder = append(groupOrder, task.GroupingKey)
}
if task.OriginRank < group.MinRank {
group.MinRank = task.OriginRank
}
group.Tasks = append(group.Tasks, task)
}
groups := make([]minContextPlanGroup, 0, len(groupMap))
for _, key := range groupOrder {
group := groupMap[key]
sort.SliceStable(group.Tasks, func(i, j int) bool {
if group.Tasks[i].OriginRank != group.Tasks[j].OriginRank {
return group.Tasks[i].OriginRank < group.Tasks[j].OriginRank
}
return group.Tasks[i].StateID < group.Tasks[j].StateID
})
groups = append(groups, *group)
}
sort.SliceStable(groups, func(i, j int) bool {
if len(groups[i].Tasks) != len(groups[j].Tasks) {
return len(groups[i].Tasks) > len(groups[j].Tasks)
}
if groups[i].MinRank != groups[j].MinRank {
return groups[i].MinRank < groups[j].MinRank
}
return groups[i].Key < groups[j].Key
})
orderedTasks := make([]minContextPlanTask, 0, len(tasks))
for _, group := range groups {
orderedTasks = append(orderedTasks, group.Tasks...)
}
sortedSlots := make([]TaskSlot, len(slots))
copy(sortedSlots, slots)
sort.SliceStable(sortedSlots, func(i, j int) bool {
if sortedSlots[i].Day != sortedSlots[j].Day {
return sortedSlots[i].Day < sortedSlots[j].Day
}
if sortedSlots[i].SlotStart != sortedSlots[j].SlotStart {
return sortedSlots[i].SlotStart < sortedSlots[j].SlotStart
}
if sortedSlots[i].SlotEnd != sortedSlots[j].SlotEnd {
return sortedSlots[i].SlotEnd < sortedSlots[j].SlotEnd
}
return i < j
})
used := make([]bool, len(sortedSlots))
result := make(map[int]TaskSlot, len(orderedTasks))
for _, task := range orderedTasks {
chosenIdx := -1
for idx, slot := range sortedSlots {
if used[idx] {
continue
}
if slot.SlotEnd-slot.SlotStart+1 != task.Span {
continue
}
chosenIdx = idx
break
}
if chosenIdx < 0 {
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.StateID)
}
used[chosenIdx] = true
result[task.StateID] = sortedSlots[chosenIdx]
}
return result, nil
}
func applyMinContextNameFallback(tasks []minContextPlanTask) {
distinctExplicit := make(map[string]struct{}, len(tasks))
distinctNonCoarse := make(map[string]struct{}, len(tasks))
for _, task := range tasks {
key := normalizeMinContextGroupingKey(task.GroupingKey)
distinctExplicit[key] = struct{}{}
if !isCoarseMinContextKey(key) {
distinctNonCoarse[key] = struct{}{}
}
}
if len(distinctNonCoarse) >= 2 {
return
}
if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
return
}
distinctInferred := make(map[string]struct{}, len(tasks))
for i := range tasks {
inferred := inferMinContextKeyFromTaskName(tasks[i].Name)
if inferred == "" {
inferred = tasks[i].GroupingKey
}
tasks[i].GroupingKey = inferred
distinctInferred[inferred] = struct{}{}
}
if len(distinctInferred) < 2 {
for i := range tasks {
tasks[i].GroupingKey = normalizeMinContextGroupingKey(tasks[i].ContextTag)
}
}
}
func uniquePositiveInts(values []int) []int {
seen := make(map[int]struct{}, len(values))
result := make([]int, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func normalizeMinContextTag(task ScheduleTask) string {
if tag := strings.TrimSpace(task.Category); tag != "" {
return tag
}
if tag := strings.TrimSpace(task.Name); tag != "" {
return tag
}
return "General"
}
func normalizeMinContextGroupingKey(tag string) string {
trimmed := strings.TrimSpace(tag)
if trimmed == "" {
return "General"
}
return trimmed
}
func isCoarseMinContextKey(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "", "general", "high-logic", "high_logic", "memory", "review":
return true
default:
return false
}
}
func inferMinContextKeyFromTaskName(name string) string {
text := strings.ToLower(strings.TrimSpace(name))
if text == "" {
return ""
}
subjectKeywordGroups := []struct {
keywords []string
groupKey string
}{
{
keywords: []string{
"概率", "随机事件", "随机变量", "条件概率", "全概率", "贝叶斯",
"分布", "大数定律", "中心极限定理", "参数估计", "期望", "方差", "协方差", "相关系数",
},
groupKey: "subject:probability",
},
{
keywords: []string{
"数制", "码制", "逻辑代数", "逻辑函数", "卡诺图", "译码器", "编码器",
"数据选择器", "触发器", "时序电路", "状态图", "状态化简", "计数器", "寄存器", "数电",
},
groupKey: "subject:digital_logic",
},
{
keywords: []string{
"命题逻辑", "谓词逻辑", "量词", "等值演算", "集合", "关系", "函数",
"图论", "欧拉回路", "哈密顿", "生成树", "离散", "组合数学", "容斥", "递推",
},
groupKey: "subject:discrete_math",
},
}
for _, group := range subjectKeywordGroups {
for _, keyword := range group.keywords {
if strings.Contains(text, keyword) {
return group.groupKey
}
}
}
return ""
}
func sortMinContextSnapshots(snapshotByID map[int]minContextSnapshot) []minContextSnapshot {
items := make([]minContextSnapshot, 0, len(snapshotByID))
for _, item := range snapshotByID {
items = append(items, item)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Slot.Day != items[j].Slot.Day {
return items[i].Slot.Day < items[j].Slot.Day
}
if items[i].Slot.SlotStart != items[j].Slot.SlotStart {
return items[i].Slot.SlotStart < items[j].Slot.SlotStart
}
if items[i].Slot.SlotEnd != items[j].Slot.SlotEnd {
return items[i].Slot.SlotEnd < items[j].Slot.SlotEnd
}
return items[i].StateID < items[j].StateID
})
return items
}
func countMinContextSwitches(ordered []minContextSnapshot) int {
if len(ordered) < 2 {
return 0
}
switches := 0
prevTag := strings.TrimSpace(ordered[0].ContextTag)
for i := 1; i < len(ordered); i++ {
currentTag := strings.TrimSpace(ordered[i].ContextTag)
if currentTag != prevTag {
switches++
}
prevTag = currentTag
}
return switches
}
func sameTaskSlot(a, b TaskSlot) bool {
return a.Day == b.Day && a.SlotStart == b.SlotStart && a.SlotEnd == b.SlotEnd
}

View File

@@ -184,7 +184,7 @@ func findFreeRangesOnDay(state *ScheduleState, day int) []freeRange {
}
// getEmbeddableTasks 获取所有可嵌入时段的任务列表。
// 条件CanEmbed == true用于 find_free 和 get_overview 输出可嵌入位置。
// 条件CanEmbed == true用于 find_first_free 和 get_overview 输出可嵌入位置。
func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
var result []*ScheduleTask
for i := range state.Tasks {
@@ -204,9 +204,10 @@ func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
func buildOverviewDayLine(state *ScheduleState, day int) string {
occupied := countDayOccupied(state, day)
tasks := getTasksOnDay(state, day)
dayLabel := formatDayLabel(state, day)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("第%d天:占%d/12", day, occupied))
sb.WriteString(fmt.Sprintf("%s:占%d/12", dayLabel, occupied))
if len(tasks) > 0 {
sb.WriteString(" — ")
@@ -228,9 +229,9 @@ func buildOverviewDayLine(state *ScheduleState, day int) string {
// buildFreeRangeLine 格式化空闲区间行。
// 格式如第3天 第1-6节6时段连续空闲
func buildFreeRangeLine(r freeRange) string {
func buildFreeRangeLine(state *ScheduleState, r freeRange) string {
dur := r.slotEnd - r.slotStart + 1
return fmt.Sprintf("第%d天 第%s%d时段连续空闲", r.day, formatSlotRange(r.slotStart, r.slotEnd), dur)
return fmt.Sprintf("%s第%s%d时段连续空闲", formatDayLabel(state, r.day), formatSlotRange(r.slotStart, r.slotEnd), dur)
}
// formatSourceName 将 source 字段转为用户可读的来源名称。

View File

@@ -135,7 +135,7 @@ func QueryRange(state *ScheduleState, day int, slotStart, slotEnd *int) string {
// 输出格式对齐 SCHEDULE_TOOLS.md 4.2 节示例。
func queryRangeFullDay(state *ScheduleState, day int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("第%d天 全天:\n\n", day))
sb.WriteString(fmt.Sprintf("%s 全天:\n\n", formatDayLabel(state, day)))
// 1. 按 6 个标准段输出1-2, 3-4, 5-6, 7-8, 9-10, 11-12
for start := 1; start <= 11; start += 2 {
@@ -174,7 +174,7 @@ func queryRangeFullDay(state *ScheduleState, day int) string {
// queryRangeSpecific 指定范围查询模式:逐节输出。
func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("第%d天 第%s\n\n", day, formatSlotRange(startSlot, endSlot)))
sb.WriteString(fmt.Sprintf("%s第%s\n\n", formatDayLabel(state, day), formatSlotRange(startSlot, endSlot)))
total := endSlot - startSlot + 1
freeCount := 0
@@ -199,16 +199,26 @@ func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) strin
// FindFirstFree 查找首个可用空位,并返回该日详细信息。
//
// 参数说明:
// 1. duration 必填,表示需要的连续时段数;
// 2. day 选填,指定单天搜索;
// 3. dayStart/dayEnd 选填,指定按天范围搜索(闭区间);
// 4. day 与 dayStart/dayEnd 互斥,避免语义冲突。
//
// 说明:
// 1. 参数与旧 find_free 保持一致duration/day
// 2. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
// 3. 当前阶段按用户要求全量返回,不做文本截断。
func FindFirstFree(state *ScheduleState, duration int, day *int) string {
// 1. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策
// 2. 当前阶段按用户要求全量返回,不做文本截断。
func FindFirstFree(state *ScheduleState, duration int, day, dayStart, dayEnd *int) string {
if duration <= 0 {
return "查询失败duration 必须大于 0。"
}
// 1. 确定搜索范围。
// 1. 参数互斥校验:单天搜索范围搜索只能二选一
if day != nil && (dayStart != nil || dayEnd != nil) {
return "查询失败day 与 day_start/day_end 不能同时传入。"
}
// 2. 确定搜索范围。
days := make([]int, 0)
if day != nil {
if *day < 1 || *day > state.Window.TotalDays {
@@ -216,12 +226,30 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
}
days = append(days, *day)
} else {
for d := 1; d <= state.Window.TotalDays; d++ {
startDay := 1
endDay := state.Window.TotalDays
if dayStart != nil {
startDay = *dayStart
}
if dayEnd != nil {
endDay = *dayEnd
}
if startDay < 1 || startDay > state.Window.TotalDays {
return fmt.Sprintf("查询失败day_start=%d 不在规划窗口范围内1-%d。", startDay, state.Window.TotalDays)
}
if endDay < 1 || endDay > state.Window.TotalDays {
return fmt.Sprintf("查询失败day_end=%d 不在规划窗口范围内1-%d。", endDay, state.Window.TotalDays)
}
if startDay > endDay {
return fmt.Sprintf("查询失败day_start=%d 不能大于 day_end=%d。", startDay, endDay)
}
for d := startDay; d <= endDay; d++ {
days = append(days, d)
}
}
// 2. 按天从前往后寻找“首个可直接放置”的空位。
// 3. 按天从前往后寻找“首个可直接放置”的空位。
for _, d := range days {
freeRanges := findFreeRangesOnDay(state, d)
for _, r := range freeRanges {
@@ -235,7 +263,7 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
}
}
// 3. 若没有纯空位,再尝试首个可嵌入宿主时段。
// 4. 若没有纯空位,再尝试首个可嵌入宿主时段。
for _, d := range days {
host, slotStart, slotEnd := findFirstEmbeddablePosition(state, d, duration)
if host != nil {
@@ -243,7 +271,7 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
}
}
// 4. 无可用位置时返回摘要,辅助 LLM 判断是否需要换天或降时长。
// 5. 无可用位置时返回摘要,辅助 LLM 判断是否需要换天或降时长。
var sb strings.Builder
sb.WriteString(fmt.Sprintf("未找到满足%d个连续时段的可用位置。\n", duration))
sb.WriteString("各天最大连续空闲区前10天\n")
@@ -261,17 +289,11 @@ func FindFirstFree(state *ScheduleState, duration int, day *int) string {
maxDur = dur
}
}
sb.WriteString(fmt.Sprintf("第%d天:最大连续空闲%d节\n", d, maxDur))
sb.WriteString(fmt.Sprintf("%s:最大连续空闲%d节\n", formatDayLabel(state, d), maxDur))
}
return sb.String()
}
// FindFree 是 find_first_free 的兼容别名。
// 保留该入口可避免旧提示词和历史轨迹中的工具名失效。
func FindFree(state *ScheduleState, duration int, day *int) string {
return FindFirstFree(state, duration, day)
}
// buildFindFirstFreeReport 构造首个可用位的详细报告。
func buildFindFirstFreeReport(
state *ScheduleState,
@@ -284,10 +306,10 @@ func buildFindFirstFreeReport(
) string {
var sb strings.Builder
if isEmbedded && host != nil {
sb.WriteString(fmt.Sprintf("首个可用位置:第%d天第%s可嵌入宿主 [%d]%s。\n",
day, formatSlotRange(slotStart, slotEnd), host.StateID, host.Name))
sb.WriteString(fmt.Sprintf("首个可用位置:%s可嵌入宿主 [%d]%s。\n",
formatDaySlotLabel(state, day, slotStart, slotEnd), host.StateID, host.Name))
} else {
sb.WriteString(fmt.Sprintf("首个可用位置:第%d天第%s可直接放置。\n", day, formatSlotRange(slotStart, slotEnd)))
sb.WriteString(fmt.Sprintf("首个可用位置:%s可直接放置。\n", formatDaySlotLabel(state, day, slotStart, slotEnd)))
}
sb.WriteString(fmt.Sprintf("匹配条件:需要%d个连续时段。\n", duration))
@@ -313,7 +335,7 @@ func buildFindFirstFreeReport(
sb.WriteString(" 无连续空闲区。\n")
} else {
for _, r := range freeRanges {
sb.WriteString(" - " + buildFreeRangeLine(r) + "\n")
sb.WriteString(" - " + buildFreeRangeLine(state, r) + "\n")
}
}
return sb.String()
@@ -385,9 +407,10 @@ func buildTaskOnlyOverviewDayLine(state *ScheduleState, day int) string {
taskOccupied := countDayTaskOccupied(state, day)
courseOccupied := totalOccupied - taskOccupied
taskEntries := collectTaskEntriesOnDay(state, day)
dayLabel := formatDayLabel(state, day)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("第%d天:总占%d/12课程占%d/12任务占%d/12", day, totalOccupied, courseOccupied, taskOccupied))
sb.WriteString(fmt.Sprintf("%s:总占%d/12课程占%d/12任务占%d/12", dayLabel, totalOccupied, courseOccupied, taskOccupied))
if len(taskEntries) == 0 {
sb.WriteString(" — 任务:无")
return sb.String()
@@ -435,7 +458,7 @@ func buildTaskOnlyOverviewList(state *ScheduleState) string {
continue
}
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 时段:%s\n",
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, formatTaskSlotsBrief(t.Slots)))
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, formatTaskSlotsBriefWithState(state, t.Slots)))
}
return sb.String()
}
@@ -545,7 +568,7 @@ func ListTasks(state *ScheduleState, category, status *string) string {
if len(suggestedTasks) == 0 {
return formatListTasksEmptyResult(statusFilter, categoryFilter)
}
return formatSuggestedList(suggestedTasks)
return formatSuggestedList(state, suggestedTasks)
}
// 6. 纯已安排模式:只输出已安排任务。
@@ -553,7 +576,7 @@ func ListTasks(state *ScheduleState, category, status *string) string {
if len(existingTasks) == 0 {
return formatListTasksEmptyResult(statusFilter, categoryFilter)
}
return formatExistingList(existingTasks)
return formatExistingList(state, existingTasks)
}
// 7. 全部模式:统计 + 分组输出。
@@ -563,11 +586,11 @@ func ListTasks(state *ScheduleState, category, status *string) string {
if len(existingTasks) > 0 {
sb.WriteString("\n已安排(existing)\n")
sb.WriteString(formatExistingList(existingTasks))
sb.WriteString(formatExistingList(state, existingTasks))
}
if len(suggestedTasks) > 0 {
sb.WriteString("\n已预排(suggested)\n")
sb.WriteString(formatSuggestedList(suggestedTasks))
sb.WriteString(formatSuggestedList(state, suggestedTasks))
}
if len(pendingTasks) > 0 {
sb.WriteString("\n待安排(pending)\n")
@@ -680,7 +703,7 @@ func GetTaskInfo(state *ScheduleState, taskID int) string {
if len(task.Slots) > 0 {
sb.WriteString("占用时段:\n")
for _, slot := range task.Slots {
sb.WriteString(fmt.Sprintf(" 第%d天 第%s\n", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd)))
sb.WriteString(fmt.Sprintf(" %s\n", formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd)))
}
}
@@ -778,14 +801,14 @@ func formatEmbedInfoForDay(state *ScheduleState, day int) string {
// formatExistingList 格式化已安排任务列表。
// 格式如: [1]高等数学(课程,固定) — 第1天(1-2节) 第4天(1-2节)
func formatExistingList(tasks []ScheduleTask) string {
func formatExistingList(state *ScheduleState, tasks []ScheduleTask) string {
var sb strings.Builder
for _, t := range tasks {
label := formatTaskLabelWithCategory(t)
// 格式化所有时段位置。
slotParts := make([]string, 0, len(t.Slots))
for _, slot := range t.Slots {
slotParts = append(slotParts, fmt.Sprintf("第%d天(%s)", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd)))
slotParts = append(slotParts, fmt.Sprintf("%s(%s)", formatDayLabel(state, slot.Day), formatSlotRange(slot.SlotStart, slot.SlotEnd)))
}
sb.WriteString(fmt.Sprintf(" %s — %s\n", label, strings.Join(slotParts, " ")))
}
@@ -794,13 +817,13 @@ func formatExistingList(tasks []ScheduleTask) string {
// formatSuggestedList 格式化已预排任务列表。
// 格式如:[3]复习线代 — 已预排至 第2天第3-4节类别学习
func formatSuggestedList(tasks []ScheduleTask) string {
func formatSuggestedList(state *ScheduleState, 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))
sb.WriteString(fmt.Sprintf("[%d]%s — 已预排至 %s类别%s\n", t.StateID, t.Name, formatTaskSlotsBriefWithState(state, t.Slots), t.Category))
}
return sb.String()
}

View File

@@ -88,11 +88,12 @@ func (r *ToolRegistry) IsWriteTool(name string) bool {
// ==================== 写工具名集合 ====================
var writeTools = map[string]bool{
"place": true,
"move": true,
"swap": true,
"batch_move": true,
"unplace": true,
"place": true,
"move": true,
"swap": true,
"batch_move": true,
"min_context_switch": true,
"unplace": true,
}
// ==================== 默认注册表 ====================
@@ -123,27 +124,14 @@ func NewDefaultRegistry() *ToolRegistry {
)
r.Register("find_first_free",
"查找首个满足时长条件的可用位置并返回该日详细负载信息。duration 必填day 选填(不填按天顺序搜索)。",
`{"name":"find_first_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"}}}`,
"查找首个满足时长条件的可用位置并返回该日详细负载信息。duration 必填;可用 day 指定单天,或用 day_start/day_end 指定搜索范围(互斥)。",
`{"name":"find_first_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
duration, ok := argsInt(args, "duration")
if !ok {
return "查询失败:缺少必填参数 duration。"
}
return FindFirstFree(state, duration, argsIntPtr(args, "day"))
},
)
// 兼容别名:保留 find_free避免旧历史轨迹中的工具调用失效。
r.Register("find_free",
"兼容别名,行为同 find_first_free。",
`{"name":"find_free","parameters":{"duration":{"type":"int","required":true},"day":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
duration, ok := argsInt(args, "duration")
if !ok {
return "查询失败:缺少必填参数 duration。"
}
return FindFirstFree(state, duration, argsIntPtr(args, "day"))
return FindFirstFree(state, duration, argsIntPtr(args, "day"), argsIntPtr(args, "day_start"), argsIntPtr(args, "day_end"))
},
)
@@ -236,6 +224,18 @@ func NewDefaultRegistry() *ToolRegistry {
},
)
r.Register("min_context_switch",
"在指定任务集合内重排 suggested 任务尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id。",
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
taskIDs, err := parseMinContextSwitchTaskIDs(args)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
return MinContextSwitch(state, taskIDs)
},
)
r.Register("unplace",
"将一个已落位任务移除恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,

View File

@@ -134,12 +134,40 @@ func countPending(state *ScheduleState) int {
// ==================== 任务时段辅助 ====================
// formatDayLabel 将 day_index 格式化为“第N天(星期X)”。
//
// 说明:
// 1. 这是工具层统一的“星期数展示口径”,避免各工具各自拼接导致输出不一致;
// 2. 当 DayMapping 可用时,追加 weekday 数字1~7
// 3. 若 DayMapping 缺失或异常退回原始“第N天”保证工具输出稳定。
func formatDayLabel(state *ScheduleState, day int) string {
base := fmt.Sprintf("第%d天", day)
if state == nil {
return base
}
_, dayOfWeek, ok := state.DayToWeekDay(day)
if !ok || dayOfWeek < 1 || dayOfWeek > 7 {
return base
}
return fmt.Sprintf("%s(星期%d)", base, dayOfWeek)
}
// formatDaySlotLabel 将“天 + 时段”拼成统一格式。
func formatDaySlotLabel(state *ScheduleState, day, slotStart, slotEnd int) string {
return fmt.Sprintf("%s第%s", formatDayLabel(state, day), formatSlotRange(slotStart, slotEnd))
}
// formatTaskSlotsBrief 将任务的时段列表格式化为简短描述。
// 如 "第1天(1-2节) 第4天(3-4节)"。
func formatTaskSlotsBrief(slots []TaskSlot) string {
return formatTaskSlotsBriefWithState(nil, slots)
}
// formatTaskSlotsBriefWithState 在时段描述里补齐星期数。
func formatTaskSlotsBriefWithState(state *ScheduleState, slots []TaskSlot) string {
parts := make([]string, 0, len(slots))
for _, slot := range slots {
parts = append(parts, fmt.Sprintf("第%d天第%s", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd)))
parts = append(parts, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd))
}
return strings.Join(parts, " ")
}
@@ -197,9 +225,10 @@ func uniqueSorted(s []int) []int {
func formatDayOccupancy(state *ScheduleState, day int) string {
tasks := getTasksOnDay(state, day)
occupied := countDayOccupied(state, day)
dayLabel := formatDayLabel(state, day)
if len(tasks) == 0 {
return fmt.Sprintf("第%d天当前占用0/12。", day)
return fmt.Sprintf("%s当前占用0/12。", dayLabel)
}
parts := make([]string, 0, len(tasks))
@@ -208,7 +237,7 @@ func formatDayOccupancy(state *ScheduleState, day int) string {
parts = append(parts, fmt.Sprintf("%s(%s)", label, formatSlotRange(td.slotStart, td.slotEnd)))
}
return fmt.Sprintf("第%d天当前占用:%s占用%d/12。", day, strings.Join(parts, " "), occupied)
return fmt.Sprintf("%s当前占用:%s占用%d/12。", dayLabel, strings.Join(parts, " "), occupied)
}
// formatFreeHint 格式化某天的空闲时段提示。

View File

@@ -52,12 +52,12 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
if conflict != nil {
// 锁定任务的冲突给出特殊提示。
if conflict.Locked {
return fmt.Sprintf("放置失败:第%d天第%s已被 [%d]%s固定占用。\n%s\n%s",
day, formatSlotRange(slotStart, slotEnd), conflict.StateID, conflict.Name,
return fmt.Sprintf("放置失败:%s已被 [%d]%s固定占用。\n%s\n%s",
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
formatDayOccupancy(state, day), formatFreeHint(state, day))
}
return fmt.Sprintf("放置失败:第%d天第%s已被 [%d]%s 占用。\n%s\n%s",
day, formatSlotRange(slotStart, slotEnd), conflict.StateID, conflict.Name,
return fmt.Sprintf("放置失败:%s已被 [%d]%s 占用。\n%s\n%s",
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
formatDayOccupancy(state, day), formatFreeHint(state, day))
}
@@ -74,8 +74,8 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
task.Status = TaskStatusSuggested
return fmt.Sprintf("已将 [%d]%s 预排并嵌入到第%d天第%s宿主[%d]%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd),
return fmt.Sprintf("已将 [%d]%s 预排并嵌入到%s宿主[%d]%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
host.StateID, host.Name,
formatDayOccupancy(state, day), countPending(state))
}
@@ -84,8 +84,8 @@ func Place(state *ScheduleState, taskID, day, slotStart int) string {
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
task.Status = TaskStatusSuggested
return fmt.Sprintf("已将 [%d]%s 预排到第%d天第%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, day, formatSlotRange(slotStart, slotEnd),
return fmt.Sprintf("已将 [%d]%s 预排到%s。\n%s\n待安排任务剩余%d个。",
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
formatDayOccupancy(state, day), countPending(state))
}
@@ -130,15 +130,15 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
// 5. 冲突检测(排除自身)。
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
if conflict != nil {
return fmt.Sprintf("移动失败:第%d天第%s已被 [%d]%s 占用。\n%s\n%s",
newDay, formatSlotRange(newSlotStart, newSlotEnd), conflict.StateID, conflict.Name,
return fmt.Sprintf("移动失败:%s已被 [%d]%s 占用。\n%s\n%s",
formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd), conflict.StateID, conflict.Name,
formatDayOccupancy(state, newDay), formatFreeHint(state, newDay))
}
// 6. 记录旧位置。
oldSlots := make([]TaskSlot, len(task.Slots))
copy(oldSlots, task.Slots)
oldDesc := formatTaskSlotsBrief(oldSlots)
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
// 7. 执行变更。
task.Slots = []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}
@@ -147,8 +147,8 @@ func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
affectedDays := collectAffectedDays(oldSlots, task.Slots)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移至第%d天第%s。\n",
task.StateID, task.Name, oldDesc, newDay, formatSlotRange(newSlotStart, newSlotEnd)))
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移至%s。\n",
task.StateID, task.Name, oldDesc, formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd)))
for _, d := range affectedDays {
sb.WriteString(formatDayOccupancy(state, d) + "\n")
}
@@ -215,8 +215,8 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
// 回滚
taskA.Slots = oldSlotsA
taskB.Slots = oldSlotsB
return fmt.Sprintf("交换失败:[%d]%s 的新位置第%d天第%s与 [%d]%s 冲突。",
taskA.StateID, taskA.Name, slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd),
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
taskA.StateID, taskA.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
conflict.StateID, conflict.Name)
}
}
@@ -226,8 +226,8 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
// 回滚
taskA.Slots = oldSlotsA
taskB.Slots = oldSlotsB
return fmt.Sprintf("交换失败:[%d]%s 的新位置第%d天第%s与 [%d]%s 冲突。",
taskB.StateID, taskB.Name, slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd),
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
taskB.StateID, taskB.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
conflict.StateID, conflict.Name)
}
}
@@ -241,10 +241,10 @@ func Swap(state *ScheduleState, taskAID, taskBID int) string {
sb.WriteString("交换完成:\n")
sb.WriteString(fmt.Sprintf(" [%d]%s%s → %s\n",
taskA.StateID, taskA.Name,
formatTaskSlotsBrief(oldSlotsA), formatTaskSlotsBrief(taskA.Slots)))
formatTaskSlotsBriefWithState(state, oldSlotsA), formatTaskSlotsBriefWithState(state, taskA.Slots)))
sb.WriteString(fmt.Sprintf(" [%d]%s%s → %s\n",
taskB.StateID, taskB.Name,
formatTaskSlotsBrief(oldSlotsB), formatTaskSlotsBrief(taskB.Slots)))
formatTaskSlotsBriefWithState(state, oldSlotsB), formatTaskSlotsBriefWithState(state, taskB.Slots)))
for _, d := range affectedDays {
sb.WriteString(formatDayOccupancy(state, d) + "\n")
}
@@ -311,8 +311,8 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
// 冲突检测(在 clone 的中间状态上,排除自身)。
conflict := findConflict(clone, m.NewDay, m.NewSlotStart, newSlotEnd, m.TaskID)
if conflict != nil {
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n冲突[%d]%s → 第%d天第%s该位置已被 [%d]%s 占用。",
task.StateID, task.Name, m.NewDay, formatSlotRange(m.NewSlotStart, newSlotEnd),
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n冲突[%d]%s → %s该位置已被 [%d]%s 占用。",
task.StateID, task.Name, formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, newSlotEnd),
conflict.StateID, conflict.Name)
}
@@ -331,9 +331,9 @@ func BatchMove(state *ScheduleState, moves []MoveRequest) string {
for _, m := range moves {
task := state.TaskByStateID(m.TaskID)
duration := taskDuration(*task)
sb.WriteString(fmt.Sprintf(" [%d]%s → 第%d天第%s\n",
task.StateID, task.Name, m.NewDay,
formatSlotRange(m.NewSlotStart, m.NewSlotStart+duration-1)))
sb.WriteString(fmt.Sprintf(" [%d]%s → %s\n",
task.StateID, task.Name,
formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, m.NewSlotStart+duration-1)))
}
for _, d := range days {
sb.WriteString(formatDayOccupancy(state, d) + "\n")
@@ -366,7 +366,7 @@ func Unplace(state *ScheduleState, taskID int) string {
// 4. 记录旧位置。
oldSlots := make([]TaskSlot, len(task.Slots))
copy(oldSlots, task.Slots)
oldDesc := formatTaskSlotsBrief(oldSlots)
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
// 5. 清理嵌入关系。
// 如果该任务嵌入到了某个宿主上,清除宿主的 EmbeddedBy。