后端: 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 的根因 前端:无 仓库:无
399 lines
14 KiB
Go
399 lines
14 KiB
Go
package newagenttools
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// ==================== 写工具:LLM 通过这些函数修改日程状态 ====================
|
||
// 所有写工具:
|
||
// - 只修改内存中的 ScheduleState,不直接写库
|
||
// - 先校验后修改,校验失败则 state 不变,返回错误信息
|
||
// - 返回自然语言描述变更结果 + 涉及天的占用摘要
|
||
|
||
// MoveRequest 是 BatchMove 的单条移动请求。
|
||
type MoveRequest struct {
|
||
TaskID int `json:"task_id"`
|
||
NewDay int `json:"new_day"`
|
||
NewSlotStart int `json:"new_slot_start"`
|
||
}
|
||
|
||
// ==================== Place ====================
|
||
|
||
// Place 将一个待安排任务预排到指定位置。
|
||
// taskID 必须是真实 pending(无 Slots)状态的任务。
|
||
// 如果目标位置有可嵌入宿主(can_embed=true 且未被嵌入),自动走嵌入逻辑。
|
||
func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||
// 1. 查找任务。
|
||
task := state.TaskByStateID(taskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("放置失败:任务ID %d 不存在。", taskID)
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
// 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)
|
||
}
|
||
|
||
// 3. 计算目标范围并校验。
|
||
slotEnd := slotStart + task.Duration - 1
|
||
if err := validateDay(state, day); err != nil {
|
||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||
}
|
||
if err := validateSlotRange(slotStart, slotEnd); err != nil {
|
||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 冲突检测。
|
||
conflict := findConflict(state, day, slotStart, slotEnd)
|
||
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,
|
||
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,
|
||
formatDayOccupancy(state, day), formatFreeHint(state, day))
|
||
}
|
||
|
||
// 5. 检查是否有可嵌入宿主。
|
||
host := findEmbedHost(state, day, slotStart, slotEnd)
|
||
|
||
// 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 = TaskStatusSuggested
|
||
|
||
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 = TaskStatusSuggested
|
||
|
||
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 允许是 suggested / existing,但不能是真实 pending。
|
||
func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||
// 1. 查找任务。
|
||
task := state.TaskByStateID(taskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("移动失败:任务ID %d 不存在。", taskID)
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
|
||
}
|
||
|
||
// 3. 校验锁定。
|
||
if err := checkLocked(*task); err != nil {
|
||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 计算新范围。
|
||
duration := taskDuration(*task)
|
||
newSlotEnd := newSlotStart + duration - 1
|
||
|
||
if err := validateDay(state, newDay); err != nil {
|
||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||
}
|
||
if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil {
|
||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||
}
|
||
|
||
// 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,
|
||
formatDayOccupancy(state, newDay), formatFreeHint(state, newDay))
|
||
}
|
||
|
||
// 6. 记录旧位置。
|
||
oldSlots := make([]TaskSlot, len(task.Slots))
|
||
copy(oldSlots, task.Slots)
|
||
oldDesc := formatTaskSlotsBrief(oldSlots)
|
||
|
||
// 7. 执行变更。
|
||
task.Slots = []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}
|
||
|
||
// 8. 收集涉及的天(去重)。
|
||
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)))
|
||
for _, d := range affectedDays {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// ==================== Swap ====================
|
||
|
||
// Swap 交换两个已落位任务的位置。
|
||
// 两个任务都必须是 suggested / existing、非锁定、总时长相同。
|
||
func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||
// 1. 查找两个任务。
|
||
taskA := state.TaskByStateID(taskAID)
|
||
if taskA == nil {
|
||
return fmt.Sprintf("交换失败:任务ID %d 不存在。", taskAID)
|
||
}
|
||
taskB := state.TaskByStateID(taskBID)
|
||
if taskB == nil {
|
||
return fmt.Sprintf("交换失败:任务ID %d 不存在。", taskBID)
|
||
}
|
||
|
||
if taskAID == taskBID {
|
||
return "交换失败:不能与自己交换。"
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
if !IsPlacedTask(*taskA) {
|
||
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskA.StateID, taskA.Name)
|
||
}
|
||
if !IsPlacedTask(*taskB) {
|
||
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskB.StateID, taskB.Name)
|
||
}
|
||
|
||
// 3. 校验锁定。
|
||
if err := checkLocked(*taskA); err != nil {
|
||
return fmt.Sprintf("交换失败:%s", err.Error())
|
||
}
|
||
if err := checkLocked(*taskB); err != nil {
|
||
return fmt.Sprintf("交换失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 校验时长。
|
||
durA := taskDuration(*taskA)
|
||
durB := taskDuration(*taskB)
|
||
if durA != durB {
|
||
return fmt.Sprintf("交换失败:[%d]%s 占%d个时段,[%d]%s 占%d个时段,时长不同无法直接交换。",
|
||
taskA.StateID, taskA.Name, durA, taskB.StateID, taskB.Name, durB)
|
||
}
|
||
|
||
// 5. 记录旧位置。
|
||
oldSlotsA := make([]TaskSlot, len(taskA.Slots))
|
||
copy(oldSlotsA, taskA.Slots)
|
||
oldSlotsB := make([]TaskSlot, len(taskB.Slots))
|
||
copy(oldSlotsB, taskB.Slots)
|
||
|
||
// 6. 交换 Slots。
|
||
taskA.Slots, taskB.Slots = taskB.Slots, taskA.Slots
|
||
|
||
// 7. 交换后冲突检测:A 的新位置(原 B 的位置)是否有第三方冲突。
|
||
// 需要排除 B(因为 B 现在在 A 的旧位置,已经被 swap 了)。
|
||
for _, slot := range taskA.Slots {
|
||
conflict := findConflict(state, slot.Day, slot.SlotStart, slot.SlotEnd, taskAID, taskBID)
|
||
if conflict != nil {
|
||
// 回滚
|
||
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),
|
||
conflict.StateID, conflict.Name)
|
||
}
|
||
}
|
||
for _, slot := range taskB.Slots {
|
||
conflict := findConflict(state, slot.Day, slot.SlotStart, slot.SlotEnd, taskAID, taskBID)
|
||
if conflict != nil {
|
||
// 回滚
|
||
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),
|
||
conflict.StateID, conflict.Name)
|
||
}
|
||
}
|
||
|
||
// 8. 成功输出。
|
||
affectedDays := collectAffectedDays(oldSlotsA, taskA.Slots)
|
||
affectedDays = append(affectedDays, collectAffectedDays(oldSlotsB, taskB.Slots)...)
|
||
affectedDays = uniqueSorted(affectedDays)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString("交换完成:\n")
|
||
sb.WriteString(fmt.Sprintf(" [%d]%s:%s → %s\n",
|
||
taskA.StateID, taskA.Name,
|
||
formatTaskSlotsBrief(oldSlotsA), formatTaskSlotsBrief(taskA.Slots)))
|
||
sb.WriteString(fmt.Sprintf(" [%d]%s:%s → %s\n",
|
||
taskB.StateID, taskB.Name,
|
||
formatTaskSlotsBrief(oldSlotsB), formatTaskSlotsBrief(taskB.Slots)))
|
||
for _, d := range affectedDays {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// ==================== BatchMove ====================
|
||
|
||
// BatchMove 原子性地批量移动多个任务。
|
||
// 全部成功才生效,任一失败则完全回滚。
|
||
func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||
if len(moves) == 0 {
|
||
return "批量移动失败:移动列表为空。"
|
||
}
|
||
|
||
// 1. 全量校验阶段(不改 state)。
|
||
for i, m := range moves {
|
||
task := state.TaskByStateID(m.TaskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求)。", m.TaskID, i+1)
|
||
}
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place(第%d条移动请求)。",
|
||
task.StateID, task.Name, i+1)
|
||
}
|
||
if err := checkLocked(*task); err != nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||
}
|
||
|
||
duration := taskDuration(*task)
|
||
newSlotEnd := m.NewSlotStart + duration - 1
|
||
if err := validateDay(state, m.NewDay); err != nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||
}
|
||
if err := validateSlotRange(m.NewSlotStart, newSlotEnd); err != nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||
}
|
||
}
|
||
|
||
// 2. 克隆 state,在克隆上执行。
|
||
clone := state.Clone()
|
||
|
||
// 收集涉及的天。
|
||
affectedDays := make(map[int]bool)
|
||
|
||
// 3. 逐个应用 + 冲突检测。
|
||
for _, m := range moves {
|
||
task := clone.TaskByStateID(m.TaskID)
|
||
duration := taskDuration(*task)
|
||
newSlotEnd := m.NewSlotStart + duration - 1
|
||
|
||
// 记录旧位置涉及的天。
|
||
for _, slot := range task.Slots {
|
||
affectedDays[slot.Day] = true
|
||
}
|
||
|
||
// 冲突检测(在 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),
|
||
conflict.StateID, conflict.Name)
|
||
}
|
||
|
||
// 应用移动。
|
||
task.Slots = []TaskSlot{{Day: m.NewDay, SlotStart: m.NewSlotStart, SlotEnd: newSlotEnd}}
|
||
affectedDays[m.NewDay] = true
|
||
}
|
||
|
||
// 4. 全部成功,将 clone 的数据写回原 state。
|
||
state.Tasks = clone.Tasks
|
||
|
||
// 5. 输出结果。
|
||
days := sortedKeys(affectedDays)
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("批量移动完成,%d个任务全部成功:\n", len(moves)))
|
||
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)))
|
||
}
|
||
for _, d := range days {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// ==================== Unplace ====================
|
||
|
||
// Unplace 将一个已落位任务移除,恢复为待安排状态。
|
||
// taskID 允许是 suggested / existing,但不能是真实 pending。
|
||
// 如果任务有嵌入关系,会自动清理双向指针。
|
||
func Unplace(state *ScheduleState, taskID int) string {
|
||
// 1. 查找任务。
|
||
task := state.TaskByStateID(taskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("移除失败:任务ID %d 不存在。", taskID)
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("移除失败:[%d]%s 已经是待安排状态。", task.StateID, task.Name)
|
||
}
|
||
|
||
// 3. 校验锁定。
|
||
if err := checkLocked(*task); err != nil {
|
||
return fmt.Sprintf("移除失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 记录旧位置。
|
||
oldSlots := make([]TaskSlot, len(task.Slots))
|
||
copy(oldSlots, task.Slots)
|
||
oldDesc := formatTaskSlotsBrief(oldSlots)
|
||
|
||
// 5. 清理嵌入关系。
|
||
// 如果该任务嵌入到了某个宿主上,清除宿主的 EmbeddedBy。
|
||
if task.EmbedHost != nil {
|
||
host := state.TaskByStateID(*task.EmbedHost)
|
||
if host != nil {
|
||
host.EmbeddedBy = nil
|
||
}
|
||
task.EmbedHost = nil
|
||
}
|
||
// 如果该任务是一个宿主且有嵌入客人,将客人也恢复为 pending。
|
||
if task.EmbeddedBy != nil {
|
||
guest := state.TaskByStateID(*task.EmbeddedBy)
|
||
if guest != nil {
|
||
// 先从嵌入时设置的 Slots 推算 Duration,再清空。
|
||
// Place 嵌入时 guest.Slots 被设置为实际占用范围,这里从中恢复时长。
|
||
if len(guest.Slots) > 0 {
|
||
guest.Duration = taskDuration(*guest)
|
||
}
|
||
guest.EmbedHost = nil
|
||
guest.Slots = nil
|
||
guest.Status = TaskStatusPending
|
||
}
|
||
task.EmbeddedBy = nil
|
||
}
|
||
|
||
// 6. 执行变更。
|
||
task.Slots = nil
|
||
task.Status = TaskStatusPending
|
||
|
||
// 7. 收集涉及的天。
|
||
affectedDays := collectAffectedDaysFromSlots(oldSlots)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移除,恢复为待安排状态。\n",
|
||
task.StateID, task.Name, oldDesc))
|
||
for _, d := range affectedDays {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
sb.WriteString(fmt.Sprintf("待安排任务剩余:%d个。", countPending(state)))
|
||
return sb.String()
|
||
}
|