Files
smartmate/backend/newAgent/tools/write_tools.go
Losita cdedd3c968 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 的根因
前端:无 仓库:无
2026-04-07 23:58:00 +08:00

399 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}