后端: 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. 同步更新调试日志文件。 前端:无 仓库:无
412 lines
15 KiB
Go
412 lines
15 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("放置失败:%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("放置失败:%s已被 [%d]%s 占用。\n%s\n%s",
|
||
formatDaySlotLabel(state, day, 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 预排并嵌入到%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))
|
||
}
|
||
|
||
// 普通路径:直接放置,并标记为 suggested。
|
||
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
|
||
task.Status = TaskStatusSuggested
|
||
|
||
return fmt.Sprintf("已将 [%d]%s 预排到%s。\n%s\n待安排任务剩余:%d个。",
|
||
task.StateID, task.Name, formatDaySlotLabel(state, day, 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 !IsSuggestedTask(*task) {
|
||
// 2.1 pending 任务尚未落位,应通过 place 安排;
|
||
// 2.2 existing 任务属于已安排事实层,不允许在 execute 微调里直接 move;
|
||
// 2.3 仅 suggested 属于“本轮可微调建议落位”。
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
|
||
}
|
||
return fmt.Sprintf("移动失败:[%d]%s 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动。", 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("移动失败:%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 := formatTaskSlotsBriefWithState(state, 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移至%s。\n",
|
||
task.StateID, task.Name, oldDesc, formatDaySlotLabel(state, newDay, 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 的新位置%s与 [%d]%s 冲突。",
|
||
taskA.StateID, taskA.Name, formatDaySlotLabel(state, slot.Day, 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 的新位置%s与 [%d]%s 冲突。",
|
||
taskB.StateID, taskB.Name, formatDaySlotLabel(state, slot.Day, 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,
|
||
formatTaskSlotsBriefWithState(state, oldSlotsA), formatTaskSlotsBriefWithState(state, taskA.Slots)))
|
||
sb.WriteString(fmt.Sprintf(" [%d]%s:%s → %s\n",
|
||
taskB.StateID, taskB.Name,
|
||
formatTaskSlotsBriefWithState(state, oldSlotsB), formatTaskSlotsBriefWithState(state, taskB.Slots)))
|
||
for _, d := range affectedDays {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// ==================== BatchMove ====================
|
||
|
||
// BatchMove 原子性地批量移动多个任务。
|
||
// moves 中每个 task_id 都必须是 suggested;existing/pending 任一命中都会整批失败。
|
||
// 全部成功才生效,任一失败则完全回滚。
|
||
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 !IsSuggestedTask(*task) {
|
||
// 1.1 保持与 Move 一致:批量移动仅允许 suggested;
|
||
// 1.2 pending / existing 任一命中都应整批失败并回滚。
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place(第%d条移动请求)。",
|
||
task.StateID, task.Name, i+1)
|
||
}
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动(第%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 → %s,该位置已被 [%d]%s 占用。",
|
||
task.StateID, task.Name, formatDaySlotLabel(state, m.NewDay, 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 → %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")
|
||
}
|
||
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 := formatTaskSlotsBriefWithState(state, 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()
|
||
}
|