Version: 0.9.15.dev.260412
后端: 1. 排程工具从 tools/ 根目录拆分为 tools/schedule 独立子包 - 12 个排程工具文件等价迁入 tools/schedule/,tools/ 根目录仅保留 registry.go 作为统一注册入口 - 所有依赖方(conv / model / node / prompt / service)import 统一切到 schedule 子包 2. Web 搜索工具链落地(tools/web 子包) - 新增 web_search(结构化检索)与 web_fetch(正文抓取)两个读工具,支持博查 API / mock 降级 - 启动流程按配置选择 provider,未识别类型自动降级为 mock,不阻断主流程 - 执行提示补齐 web 工具使用约束与返回值示例 - config.example.yaml 补齐 websearch 配置段 前端:无 仓库:无
This commit is contained in:
61
backend/newAgent/tools/schedule/arg_guard.go
Normal file
61
backend/newAgent/tools/schedule/arg_guard.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateToolArgsStrict 校验工具参数是否全部命中 schema 白名单。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做“字段名是否允许”的校验,不校验字段值合法性;
|
||||
// 2. 发现未知字段时直接报错,避免静默忽略导致范围漂移;
|
||||
// 3. 该函数不做别名兼容,调用方应自行传入 schema 中允许的字段。
|
||||
func validateToolArgsStrict(args map[string]any, allowedKeys []string) error {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(allowedKeys))
|
||||
for _, key := range allowedKeys {
|
||||
allowed[strings.TrimSpace(key)] = struct{}{}
|
||||
}
|
||||
|
||||
unknown := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
unknown = append(unknown, trimmed)
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Strings(unknown)
|
||||
hint := "请仅使用当前工具 schema 中声明的参数字段。"
|
||||
if containsAnyUnknownArg(unknown, "day_from", "day_to") {
|
||||
hint = "请仅使用当前工具 schema 中声明的参数字段;day_from/day_to 不受支持,请改用 day_start/day_end。"
|
||||
}
|
||||
return fmt.Errorf("参数非法:%s。%s", strings.Join(unknown, "、"), hint)
|
||||
}
|
||||
|
||||
func containsAnyUnknownArg(keys []string, targets ...string) bool {
|
||||
if len(keys) == 0 || len(targets) == 0 {
|
||||
return false
|
||||
}
|
||||
targetSet := make(map[string]struct{}, len(targets))
|
||||
for _, target := range targets {
|
||||
targetSet[strings.TrimSpace(target)] = struct{}{}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, ok := targetSet[strings.TrimSpace(key)]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
125
backend/newAgent/tools/schedule/args.go
Normal file
125
backend/newAgent/tools/schedule/args.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package schedule
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ==================== 参数解析辅助 ====================
|
||||
// 这些函数专门用于从 LLM 输出的 map[string]any 中提取工具参数。
|
||||
// JSON 反序列化后数字默认为 float64,字符串为 string,需要类型断言。
|
||||
|
||||
// argsInt 从 map 中提取 int 值。支持 float64(JSON 反序列化的默认类型)。
|
||||
func ArgsInt(args map[string]any, key string) (int, bool) {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n), true
|
||||
case int:
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// argsString 从 map 中提取 string 值。
|
||||
func ArgsString(args map[string]any, key string) (string, bool) {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// argsIntPtr 从 map 中提取可选 int 值,不存在返回 nil。
|
||||
func ArgsIntPtr(args map[string]any, key string) *int {
|
||||
v, ok := ArgsInt(args, key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// argsStringPtr 从 map 中提取可选 string 值,不存在返回 nil。
|
||||
func ArgsStringPtr(args map[string]any, key string) *string {
|
||||
v, ok := ArgsString(args, key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
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"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("缺少 moves 参数")
|
||||
}
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("moves 参数必须是数组")
|
||||
}
|
||||
moves := make([]MoveRequest, 0, len(arr))
|
||||
for i, item := range arr {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("moves[%d] 不是有效对象", i)
|
||||
}
|
||||
taskID, ok := ArgsInt(m, "task_id")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("moves[%d].task_id 缺失或无效", i)
|
||||
}
|
||||
newDay, ok := ArgsInt(m, "new_day")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("moves[%d].new_day 缺失或无效", i)
|
||||
}
|
||||
newSlotStart, ok := ArgsInt(m, "new_slot_start")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("moves[%d].new_slot_start 缺失或无效", i)
|
||||
}
|
||||
moves = append(moves, MoveRequest{
|
||||
TaskID: taskID,
|
||||
NewDay: newDay,
|
||||
NewSlotStart: newSlotStart,
|
||||
})
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
693
backend/newAgent/tools/schedule/compound_tools.go
Normal file
693
backend/newAgent/tools/schedule/compound_tools.go
Normal file
@@ -0,0 +1,693 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
|
||||
)
|
||||
|
||||
var spreadEvenAllowedArgs = []string{
|
||||
"task_ids",
|
||||
"task_id",
|
||||
"limit",
|
||||
"allow_embed",
|
||||
"day",
|
||||
"day_start",
|
||||
"day_end",
|
||||
"day_scope",
|
||||
"day_of_week",
|
||||
"week",
|
||||
"week_filter",
|
||||
"week_from",
|
||||
"week_to",
|
||||
"slot_type",
|
||||
"slot_types",
|
||||
"exclude_sections",
|
||||
"after_section",
|
||||
"before_section",
|
||||
}
|
||||
|
||||
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
|
||||
type minContextSnapshot struct {
|
||||
StateID int
|
||||
Name string
|
||||
ContextTag string
|
||||
Slot TaskSlot
|
||||
}
|
||||
|
||||
// refineTaskCandidate 是复合规划器使用的任务输入。
|
||||
type refineTaskCandidate struct {
|
||||
TaskID int
|
||||
Week int
|
||||
DayOfWeek int
|
||||
SectionFrom int
|
||||
SectionTo int
|
||||
Name string
|
||||
ContextTag string
|
||||
OriginRank int
|
||||
}
|
||||
|
||||
// compositeIDMapper 负责维护 state_id 与 logic 规划入参 ID 的双向映射。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 当前阶段使用等值映射(logicID=stateID),保证行为不变;
|
||||
// 2. 保留独立适配层,后续若切到真实 task_item_id,只需改这里;
|
||||
// 3. 通过双向映射保证“入参转换 + 结果回填”一致。
|
||||
type compositeIDMapper struct {
|
||||
stateToLogic map[int]int
|
||||
logicToState map[int]int
|
||||
}
|
||||
|
||||
// buildCompositeIDMapper 构建并校验本轮复合工具的 ID 映射。
|
||||
func buildCompositeIDMapper(stateIDs []int) (*compositeIDMapper, error) {
|
||||
mapper := &compositeIDMapper{
|
||||
stateToLogic: make(map[int]int, len(stateIDs)),
|
||||
logicToState: make(map[int]int, len(stateIDs)),
|
||||
}
|
||||
for _, stateID := range stateIDs {
|
||||
if stateID <= 0 {
|
||||
return nil, fmt.Errorf("存在非法 state_id=%d", stateID)
|
||||
}
|
||||
if _, exists := mapper.stateToLogic[stateID]; exists {
|
||||
return nil, fmt.Errorf("state_id=%d 重复", stateID)
|
||||
}
|
||||
// 当前迁移阶段采用等值映射,先把“映射机制”跑通。
|
||||
logicID := stateID
|
||||
mapper.stateToLogic[stateID] = logicID
|
||||
mapper.logicToState[logicID] = stateID
|
||||
}
|
||||
return mapper, nil
|
||||
}
|
||||
|
||||
// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排;
|
||||
// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务;
|
||||
// 3. 采用原子提交:任一校验失败则整体不生效。
|
||||
func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
|
||||
if state == nil {
|
||||
return "减少上下文切换失败:日程状态为空。"
|
||||
}
|
||||
|
||||
// 1. 收集任务并做前置校验,确保规划输入可用。
|
||||
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。
|
||||
currentSlots := buildCurrentSlotsFromPlannerTasks(logicTasks)
|
||||
plannedMoves, err := compositelogic.PlanMinContextSwitchMoves(logicTasks, currentSlots, compositelogic.RefineCompositePlanOptions{})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 3. 映射回工具态坐标并在提交前做完整校验。
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||||
}
|
||||
for taskID, after := range afterByID {
|
||||
before := beforeByID[taskID]
|
||||
if err := validateDay(state, after.Slot.Day); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
|
||||
return fmt.Sprintf("减少上下文切换失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
|
||||
return fmt.Sprintf(
|
||||
"减少上下文切换失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
|
||||
conflict.StateID,
|
||||
conflict.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 全量通过后再原子提交,避免半成品状态。
|
||||
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())
|
||||
}
|
||||
|
||||
// SpreadEven 在给定任务集合内执行“均匀化铺开”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅处理 suggested 且已落位任务;
|
||||
// 2. 先按筛选条件收集候选坑位,再调用确定性规划器;
|
||||
// 3. 通过统一校验后原子提交,失败不落地。
|
||||
func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string {
|
||||
if state == nil {
|
||||
return "均匀化调整失败:日程状态为空。"
|
||||
}
|
||||
// 0. 参数白名单校验:未知字段直接失败,避免静默忽略导致候选范围漂移。
|
||||
if err := validateToolArgsStrict(args, spreadEvenAllowedArgs); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
|
||||
plannerTasks, beforeByID, excludeIDs, idMapper, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
logicTasks, err := toLogicPlannerTasks(plannerTasks, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。
|
||||
spanNeed := make(map[int]int, len(logicTasks))
|
||||
for _, task := range logicTasks {
|
||||
spanNeed[task.SectionTo-task.SectionFrom+1]++
|
||||
}
|
||||
candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。
|
||||
dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots)
|
||||
plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{
|
||||
ExistingDayLoad: dayLoadBaseline,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
|
||||
// 4. 回填 + 校验 + 原子提交。
|
||||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||||
}
|
||||
for taskID, after := range afterByID {
|
||||
before := beforeByID[taskID]
|
||||
if err := validateDay(state, after.Slot.Day); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标天非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(after.Slot.SlotStart, after.Slot.SlotEnd); err != nil {
|
||||
return fmt.Sprintf("均匀化调整失败:任务 [%d]%s 目标节次非法:%s。", before.StateID, before.Name, err.Error())
|
||||
}
|
||||
if conflict := findConflict(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd, excludeIDs...); conflict != nil {
|
||||
return fmt.Sprintf(
|
||||
"均匀化调整失败:任务 [%d]%s 目标位置 %s 与 [%d]%s 冲突。",
|
||||
before.StateID,
|
||||
before.Name,
|
||||
formatDaySlotLabel(state, after.Slot.Day, after.Slot.SlotStart, after.Slot.SlotEnd),
|
||||
conflict.StateID,
|
||||
conflict.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 个。\n",
|
||||
len(beforeByID), len(candidateSlots),
|
||||
))
|
||||
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) {
|
||||
return ParseCompositeTaskIDs(args)
|
||||
}
|
||||
|
||||
func ParseSpreadEvenTaskIDs(args map[string]any) ([]int, error) {
|
||||
return ParseCompositeTaskIDs(args)
|
||||
}
|
||||
|
||||
func ParseCompositeTaskIDs(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)")
|
||||
}
|
||||
|
||||
// collectCompositePlannerTasks 统一收集复合工具输入任务,并做“可移动 suggested”校验。
|
||||
func collectCompositePlannerTasks(
|
||||
state *ScheduleState,
|
||||
taskIDs []int,
|
||||
toolLabel string,
|
||||
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) {
|
||||
normalizedIDs := uniquePositiveInts(taskIDs)
|
||||
if len(normalizedIDs) < 2 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:task_ids 至少需要 2 个有效任务 ID", toolLabel)
|
||||
}
|
||||
|
||||
idMapper, err := buildCompositeIDMapper(normalizedIDs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:ID 映射构建失败:%s", toolLabel, err.Error())
|
||||
}
|
||||
|
||||
plannerTasks := make([]refineTaskCandidate, 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 nil, nil, nil, nil, fmt.Errorf("%s失败:任务ID %d 不存在", toolLabel, taskID)
|
||||
}
|
||||
if !IsSuggestedTask(*task) {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 不是 suggested 任务,仅 suggested 可参与该工具", toolLabel, task.StateID, task.Name)
|
||||
}
|
||||
if err := checkLocked(*task); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:%s", toolLabel, err.Error())
|
||||
}
|
||||
if len(task.Slots) != 1 {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 当前包含 %d 段时段,暂不支持该形态", toolLabel, task.StateID, task.Name, len(task.Slots))
|
||||
}
|
||||
|
||||
slot := task.Slots[0]
|
||||
if err := validateDay(state, slot.Day); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
}
|
||||
if err := validateSlotRange(slot.SlotStart, slot.SlotEnd); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
|
||||
}
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
return nil, nil, nil, nil, fmt.Errorf("%s失败:[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
|
||||
}
|
||||
|
||||
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, refineTaskCandidate{
|
||||
TaskID: task.StateID,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
ContextTag: contextTag,
|
||||
OriginRank: rank + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return plannerTasks, beforeByID, excludeIDs, idMapper, nil
|
||||
}
|
||||
|
||||
// toLogicPlannerTasks 将工具层任务结构映射为 logic 规划器输入。
|
||||
func toLogicPlannerTasks(tasks []refineTaskCandidate, idMapper *compositeIDMapper) ([]compositelogic.RefineTaskCandidate, error) {
|
||||
if len(tasks) == 0 {
|
||||
return nil, fmt.Errorf("任务列表为空")
|
||||
}
|
||||
if idMapper == nil {
|
||||
return nil, fmt.Errorf("ID 映射为空")
|
||||
}
|
||||
result := make([]compositelogic.RefineTaskCandidate, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
logicID, ok := idMapper.stateToLogic[task.TaskID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("任务 state_id=%d 缺少 logic 映射", task.TaskID)
|
||||
}
|
||||
result = append(result, compositelogic.RefineTaskCandidate{
|
||||
TaskItemID: logicID,
|
||||
Week: task.Week,
|
||||
DayOfWeek: task.DayOfWeek,
|
||||
SectionFrom: task.SectionFrom,
|
||||
SectionTo: task.SectionTo,
|
||||
Name: task.Name,
|
||||
ContextTag: task.ContextTag,
|
||||
OriginRank: task.OriginRank,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildCurrentSlotsFromPlannerTasks(tasks []compositelogic.RefineTaskCandidate) []compositelogic.RefineSlotCandidate {
|
||||
slots := make([]compositelogic.RefineSlotCandidate, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
slots = append(slots, compositelogic.RefineSlotCandidate{
|
||||
Week: task.Week,
|
||||
DayOfWeek: task.DayOfWeek,
|
||||
SectionFrom: task.SectionFrom,
|
||||
SectionTo: task.SectionTo,
|
||||
})
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
func buildAfterSnapshotsFromPlannedMoves(
|
||||
state *ScheduleState,
|
||||
beforeByID map[int]minContextSnapshot,
|
||||
plannedMoves []compositelogic.RefineMovePlanItem,
|
||||
idMapper *compositeIDMapper,
|
||||
) (map[int]minContextSnapshot, error) {
|
||||
if len(plannedMoves) == 0 {
|
||||
return nil, fmt.Errorf("规划结果为空")
|
||||
}
|
||||
if idMapper == nil {
|
||||
return nil, fmt.Errorf("ID 映射为空")
|
||||
}
|
||||
|
||||
moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves))
|
||||
for _, move := range plannedMoves {
|
||||
stateID, ok := idMapper.logicToState[move.TaskItemID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID)
|
||||
}
|
||||
if _, exists := moveByID[stateID]; exists {
|
||||
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID)
|
||||
}
|
||||
moveByID[stateID] = move
|
||||
}
|
||||
|
||||
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
|
||||
for taskID, before := range beforeByID {
|
||||
move, ok := moveByID[taskID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("规划结果不完整:缺少任务 id=%d", taskID)
|
||||
}
|
||||
day, ok := state.WeekDayToDay(move.ToWeek, move.ToDay)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("任务 id=%d 目标 week/day 无法映射到 day_index:W%dD%d", taskID, move.ToWeek, move.ToDay)
|
||||
}
|
||||
afterByID[taskID] = minContextSnapshot{
|
||||
StateID: before.StateID,
|
||||
Name: before.Name,
|
||||
ContextTag: before.ContextTag,
|
||||
Slot: TaskSlot{
|
||||
Day: day,
|
||||
SlotStart: move.ToSectionFrom,
|
||||
SlotEnd: move.ToSectionTo,
|
||||
},
|
||||
}
|
||||
}
|
||||
return afterByID, nil
|
||||
}
|
||||
|
||||
func collectSpreadEvenCandidateSlotsBySpan(
|
||||
state *ScheduleState,
|
||||
args map[string]any,
|
||||
spanNeed map[int]int,
|
||||
) ([]compositelogic.RefineSlotCandidate, error) {
|
||||
if len(spanNeed) == 0 {
|
||||
return nil, fmt.Errorf("未识别到任务跨度需求")
|
||||
}
|
||||
|
||||
spans := make([]int, 0, len(spanNeed))
|
||||
for span := range spanNeed {
|
||||
spans = append(spans, span)
|
||||
}
|
||||
sort.Ints(spans)
|
||||
|
||||
allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16)
|
||||
seen := make(map[string]struct{}, 64)
|
||||
for _, span := range spans {
|
||||
required := spanNeed[span]
|
||||
queryArgs := buildSpreadEvenSlotQueryArgs(args, span, required)
|
||||
raw := QueryAvailableSlots(state, queryArgs)
|
||||
|
||||
var failed struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(raw), &failed)
|
||||
if strings.TrimSpace(failed.Error) != "" {
|
||||
return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败:%s", span, strings.TrimSpace(failed.Error))
|
||||
}
|
||||
|
||||
var payload queryAvailableSlotsResult
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("解析跨度=%d 的候选坑位结果失败:%v", span, err)
|
||||
}
|
||||
if len(payload.Slots) < required {
|
||||
return nil, fmt.Errorf("跨度=%d 可用坑位不足:required=%d, got=%d", span, required, len(payload.Slots))
|
||||
}
|
||||
|
||||
for _, slot := range payload.Slots {
|
||||
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SlotStart, slot.SlotEnd)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
allSlots = append(allSlots, compositelogic.RefineSlotCandidate{
|
||||
Week: slot.Week,
|
||||
DayOfWeek: slot.DayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
return allSlots, nil
|
||||
}
|
||||
|
||||
func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) map[string]any {
|
||||
query := make(map[string]any, 16)
|
||||
query["span"] = span
|
||||
|
||||
limit := required * 6
|
||||
if limit < required {
|
||||
limit = required
|
||||
}
|
||||
if customLimit, ok := readIntAny(args, "limit"); ok && customLimit > limit {
|
||||
limit = customLimit
|
||||
}
|
||||
query["limit"] = limit
|
||||
query["allow_embed"] = readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
|
||||
|
||||
for _, key := range []string{"day", "day_start", "day_end", "week", "week_from", "week_to", "day_scope", "after_section", "before_section"} {
|
||||
if value, ok := args[key]; ok {
|
||||
query[key] = value
|
||||
}
|
||||
}
|
||||
if week, ok := readIntAny(args, "to_week", "target_week", "new_week"); ok {
|
||||
query["week"] = week
|
||||
}
|
||||
if day, ok := readIntAny(args, "to_day", "target_day", "target_day_of_week", "new_day"); ok {
|
||||
query["day_of_week"] = []int{day}
|
||||
}
|
||||
|
||||
if values := uniquePositiveInts(readIntSliceAny(args, "week_filter", "weeks")); len(values) > 0 {
|
||||
query["week_filter"] = values
|
||||
}
|
||||
if values := uniqueInts(readIntSliceAny(args, "day_of_week", "days", "day_filter")); len(values) > 0 {
|
||||
query["day_of_week"] = values
|
||||
}
|
||||
if values := uniqueInts(readIntSliceAny(args, "exclude_sections", "exclude_section")); len(values) > 0 {
|
||||
query["exclude_sections"] = values
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func buildSpreadEvenDayLoadBaseline(
|
||||
state *ScheduleState,
|
||||
excludeTaskIDs []int,
|
||||
slots []compositelogic.RefineSlotCandidate,
|
||||
) map[string]int {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetDays := make(map[string]struct{}, len(slots))
|
||||
for _, slot := range slots {
|
||||
targetDays[composeDayKey(slot.Week, slot.DayOfWeek)] = struct{}{}
|
||||
}
|
||||
if len(targetDays) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
excludeSet := make(map[int]struct{}, len(excludeTaskIDs))
|
||||
for _, id := range excludeTaskIDs {
|
||||
excludeSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
load := make(map[string]int, len(targetDays))
|
||||
for _, task := range state.Tasks {
|
||||
if !IsSuggestedTask(task) {
|
||||
continue
|
||||
}
|
||||
if _, excluded := excludeSet[task.StateID]; excluded {
|
||||
continue
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := composeDayKey(week, dayOfWeek)
|
||||
if _, inTarget := targetDays[key]; !inTarget {
|
||||
continue
|
||||
}
|
||||
load[key]++
|
||||
}
|
||||
}
|
||||
return load
|
||||
}
|
||||
|
||||
func composeDayKey(week, day int) string {
|
||||
return fmt.Sprintf("%d-%d", week, day)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
272
backend/newAgent/tools/schedule/queue_tools.go
Normal file
272
backend/newAgent/tools/schedule/queue_tools.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type queueTaskSlot struct {
|
||||
Day int `json:"day"`
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
SlotStart int `json:"slot_start"`
|
||||
SlotEnd int `json:"slot_end"`
|
||||
}
|
||||
|
||||
type queueTaskItem struct {
|
||||
TaskID int `json:"task_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
TaskClassID int `json:"task_class_id,omitempty"`
|
||||
Slots []queueTaskSlot `json:"slots,omitempty"`
|
||||
}
|
||||
|
||||
type queuePopHeadResult struct {
|
||||
Tool string `json:"tool"`
|
||||
HasHead bool `json:"has_head"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
Current *queueTaskItem `json:"current,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type queueApplyHeadMoveResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
TaskID int `json:"task_id,omitempty"`
|
||||
CurrentAttempt int `json:"current_attempt,omitempty"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
type queueSkipHeadResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Success bool `json:"success"`
|
||||
SkippedTaskID int `json:"skipped_task_id,omitempty"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type queueStatusResult struct {
|
||||
Tool string `json:"tool"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
CurrentTaskID int `json:"current_task_id,omitempty"`
|
||||
CurrentAttempt int `json:"current_attempt,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
NextTaskIDs []int `json:"next_task_ids,omitempty"`
|
||||
Current *queueTaskItem `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
// QueuePopHead 从队列弹出队首任务(若已有 current 则复用),并返回当前处理对象。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先保证队列容器存在,避免空指针;
|
||||
// 2. 若 current 已存在,直接复用,确保 apply/skip 前不会切换处理对象;
|
||||
// 3. 若 current 为空则从 pending 弹出队首;
|
||||
// 4. 若没有可处理任务,返回 has_head=false,由 LLM 收口或重筛选。
|
||||
func QueuePopHead(state *ScheduleState, _ map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_pop_head","has_head":false,"error":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
taskID := popOrGetCurrentTaskID(state)
|
||||
|
||||
result := queuePopHeadResult{
|
||||
Tool: "queue_pop_head",
|
||||
HasHead: taskID > 0,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
LastError: strings.TrimSpace(queue.LastError),
|
||||
}
|
||||
if taskID > 0 {
|
||||
result.Current = buildQueueTaskItem(state, taskID)
|
||||
}
|
||||
return mustJSON(result, "queue_pop_head")
|
||||
}
|
||||
|
||||
// QueueApplyHeadMove 将当前队首任务移动到指定位置,成功后自动完成并出队。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 只能处理 current 任务,禁止越级指定 task_id,避免 LLM 绕过队列直接乱改;
|
||||
// 2. 成功时标记 completed 并清空 current;
|
||||
// 3. 失败时保留 current 并累加 attempt,让 LLM 继续换坑位重试或 skip。
|
||||
func QueueApplyHeadMove(state *ScheduleState, args map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_apply_head_move","success":false,"result":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
currentID := queue.CurrentTaskID
|
||||
if currentID <= 0 {
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: false,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: "队列中没有正在处理的任务。请先调用 queue_pop_head。",
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
|
||||
newDay, ok := ArgsInt(args, "new_day")
|
||||
if !ok {
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: false,
|
||||
TaskID: currentID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: "缺少必填参数 new_day。",
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
newSlotStart, ok := ArgsInt(args, "new_slot_start")
|
||||
if !ok {
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: false,
|
||||
TaskID: currentID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: "缺少必填参数 new_slot_start。",
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
|
||||
// 1. 真正执行仍复用既有 move 校验链路,避免重复实现一套冲突判断。
|
||||
// 2. 失败时仅更新队列 attempt,不改 current,确保同一任务可继续重试。
|
||||
resultText := Move(state, currentID, newDay, newSlotStart)
|
||||
success := !strings.Contains(resultText, "移动失败")
|
||||
if success {
|
||||
markCurrentTaskCompleted(state)
|
||||
} else {
|
||||
bumpCurrentTaskAttempt(state, resultText)
|
||||
}
|
||||
|
||||
queue = ensureTaskProcessingQueue(state)
|
||||
return mustJSON(queueApplyHeadMoveResult{
|
||||
Tool: "queue_apply_head_move",
|
||||
Success: success,
|
||||
TaskID: currentID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Result: strings.TrimSpace(resultText),
|
||||
}, "queue_apply_head_move")
|
||||
}
|
||||
|
||||
// QueueSkipHead 跳过当前队首任务。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只修改队列运行态,不改排程结果;
|
||||
// 2. current 必须存在,否则返回失败提示;
|
||||
// 3. 跳过后由下一轮 queue_pop_head 继续取下一项。
|
||||
func QueueSkipHead(state *ScheduleState, args map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_skip_head","success":false,"reason":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
currentID := queue.CurrentTaskID
|
||||
if currentID <= 0 {
|
||||
return mustJSON(queueSkipHeadResult{
|
||||
Tool: "queue_skip_head",
|
||||
Success: false,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Reason: "没有可跳过的 current 任务,请先 queue_pop_head。",
|
||||
}, "queue_skip_head")
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if raw, ok := ArgsString(args, "reason"); ok {
|
||||
reason = strings.TrimSpace(raw)
|
||||
}
|
||||
markCurrentTaskSkipped(state)
|
||||
queue = ensureTaskProcessingQueue(state)
|
||||
return mustJSON(queueSkipHeadResult{
|
||||
Tool: "queue_skip_head",
|
||||
Success: true,
|
||||
SkippedTaskID: currentID,
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
Reason: reason,
|
||||
}, "queue_skip_head")
|
||||
}
|
||||
|
||||
// QueueStatus 查询当前队列状态。
|
||||
func QueueStatus(state *ScheduleState, _ map[string]any) string {
|
||||
if state == nil {
|
||||
return `{"tool":"queue_status","pending_count":0,"completed_count":0,"skipped_count":0,"last_error":"state is nil"}`
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
nextIDs := queue.PendingTaskIDs
|
||||
if len(nextIDs) > 5 {
|
||||
nextIDs = nextIDs[:5]
|
||||
}
|
||||
|
||||
result := queueStatusResult{
|
||||
Tool: "queue_status",
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
CurrentTaskID: queue.CurrentTaskID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
LastError: strings.TrimSpace(queue.LastError),
|
||||
NextTaskIDs: append([]int(nil), nextIDs...),
|
||||
}
|
||||
if queue.CurrentTaskID > 0 {
|
||||
result.Current = buildQueueTaskItem(state, queue.CurrentTaskID)
|
||||
}
|
||||
return mustJSON(result, "queue_status")
|
||||
}
|
||||
|
||||
// buildQueueTaskItem 构造队列任务快照,供 pop/status 返回。
|
||||
func buildQueueTaskItem(state *ScheduleState, taskID int) *queueTaskItem {
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
item := &queueTaskItem{
|
||||
TaskID: task.StateID,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
Category: strings.TrimSpace(task.Category),
|
||||
Status: buildTaskStatusLabel(*task),
|
||||
Duration: task.Duration,
|
||||
TaskClassID: task.TaskClassID,
|
||||
Slots: make([]queueTaskSlot, 0, len(task.Slots)),
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item.Slots = append(item.Slots, queueTaskSlot{
|
||||
Day: slot.Day,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SlotStart: slot.SlotStart,
|
||||
SlotEnd: slot.SlotEnd,
|
||||
})
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func mustJSON(v any, toolName string) string {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"%s","success":false,"error":"json encode failed"}`, toolName)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
986
backend/newAgent/tools/schedule/read_filter_tools.go
Normal file
986
backend/newAgent/tools/schedule/read_filter_tools.go
Normal file
@@ -0,0 +1,986 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// queryAvailableSlotsResult 描述 query_available_slots 的结构化返回。
|
||||
type queryAvailableSlotsResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Count int `json:"count"`
|
||||
StrictCount int `json:"strict_count"`
|
||||
EmbeddedCount int `json:"embedded_count"`
|
||||
FallbackUsed bool `json:"fallback_used"`
|
||||
DayScope string `json:"day_scope"`
|
||||
DayOfWeek []int `json:"day_of_week"`
|
||||
WeekFilter []int `json:"week_filter"`
|
||||
WeekFrom int `json:"week_from"`
|
||||
WeekTo int `json:"week_to"`
|
||||
Span int `json:"span"`
|
||||
AllowEmbed bool `json:"allow_embed"`
|
||||
ExcludeSections []int `json:"exclude_sections"`
|
||||
Slots []queryAvailableSlotItem `json:"slots"`
|
||||
}
|
||||
|
||||
// queryAvailableSlotItem 描述单个候选坑位。
|
||||
type queryAvailableSlotItem struct {
|
||||
Day int `json:"day"`
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
SlotStart int `json:"slot_start"`
|
||||
SlotEnd int `json:"slot_end"`
|
||||
SlotType string `json:"slot_type,omitempty"`
|
||||
}
|
||||
|
||||
// queryTargetTasksResult 描述 query_target_tasks 的结构化返回。
|
||||
type queryTargetTasksResult struct {
|
||||
Tool string `json:"tool"`
|
||||
Count int `json:"count"`
|
||||
Status string `json:"status"`
|
||||
DayScope string `json:"day_scope"`
|
||||
DayOfWeek []int `json:"day_of_week"`
|
||||
WeekFilter []int `json:"week_filter"`
|
||||
WeekFrom int `json:"week_from"`
|
||||
WeekTo int `json:"week_to"`
|
||||
Enqueue bool `json:"enqueue"`
|
||||
Enqueued int `json:"enqueued"`
|
||||
Queue *queryTargetQueueInfo `json:"queue,omitempty"`
|
||||
Items []queryTargetTaskItem `json:"items"`
|
||||
}
|
||||
|
||||
// queryTargetQueueInfo 描述 query_target_tasks 入队后的队列摘要。
|
||||
type queryTargetQueueInfo struct {
|
||||
PendingCount int `json:"pending_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
CurrentTaskID int `json:"current_task_id,omitempty"`
|
||||
CurrentAttempt int `json:"current_attempt,omitempty"`
|
||||
}
|
||||
|
||||
// queryTargetTaskItem 描述候选任务。
|
||||
type queryTargetTaskItem struct {
|
||||
TaskID int `json:"task_id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
TaskClassID int `json:"task_class_id,omitempty"`
|
||||
Slots []queryTargetTaskSlot `json:"slots,omitempty"`
|
||||
}
|
||||
|
||||
// queryTargetTaskSlot 描述任务在工具状态中的坐标。
|
||||
type queryTargetTaskSlot struct {
|
||||
Day int `json:"day"`
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
SlotStart int `json:"slot_start"`
|
||||
SlotEnd int `json:"slot_end"`
|
||||
}
|
||||
|
||||
// queryAvailableOptions 是 query_available_slots 的参数快照。
|
||||
type queryAvailableOptions struct {
|
||||
DayScope string
|
||||
DayOfWeekSet map[int]struct{}
|
||||
WeekSet map[int]struct{}
|
||||
WeekFrom int
|
||||
WeekTo int
|
||||
Span int
|
||||
Limit int
|
||||
AllowEmbed bool
|
||||
ExcludedSection map[int]struct{}
|
||||
AfterSection *int
|
||||
BeforeSection *int
|
||||
ExactFrom *int
|
||||
ExactTo *int
|
||||
}
|
||||
|
||||
// queryTargetOptions 是 query_target_tasks 的参数快照。
|
||||
type queryTargetOptions struct {
|
||||
DayScope string
|
||||
DayOfWeekSet map[int]struct{}
|
||||
WeekSet map[int]struct{}
|
||||
WeekFrom int
|
||||
WeekTo int
|
||||
Status string
|
||||
Limit int
|
||||
TaskIDSet map[int]struct{}
|
||||
Category string
|
||||
Enqueue bool
|
||||
ResetQueue bool
|
||||
}
|
||||
|
||||
var (
|
||||
queryAvailableAllowedArgs = []string{
|
||||
"span",
|
||||
"duration",
|
||||
"limit",
|
||||
"allow_embed",
|
||||
"day",
|
||||
"day_start",
|
||||
"day_end",
|
||||
"day_scope",
|
||||
"day_of_week",
|
||||
"week",
|
||||
"week_filter",
|
||||
"week_from",
|
||||
"week_to",
|
||||
"slot_type",
|
||||
"slot_types",
|
||||
"exclude_sections",
|
||||
"after_section",
|
||||
"before_section",
|
||||
"section_from",
|
||||
"section_to",
|
||||
}
|
||||
queryTargetAllowedArgs = []string{
|
||||
"status",
|
||||
"category",
|
||||
"limit",
|
||||
"day_scope",
|
||||
"day",
|
||||
"day_start",
|
||||
"day_end",
|
||||
"day_of_week",
|
||||
"week",
|
||||
"week_filter",
|
||||
"week_from",
|
||||
"week_to",
|
||||
"task_ids",
|
||||
"task_id",
|
||||
"task_item_ids",
|
||||
"task_item_id",
|
||||
"enqueue",
|
||||
"reset_queue",
|
||||
}
|
||||
)
|
||||
|
||||
// QueryAvailableSlots 返回“候选坑位池”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责读状态并返回结构化 JSON,不做任何写入;
|
||||
// 2. 优先返回纯空位(strict),不足时再补可嵌入位(embedded);
|
||||
// 3. 不负责移动策略决策,最终落点由模型结合目标再选择。
|
||||
func QueryAvailableSlots(state *ScheduleState, args map[string]any) string {
|
||||
// 0. 先做字段白名单校验:未知参数直接报错,避免静默忽略造成范围漂移。
|
||||
if err := validateToolArgsStrict(args, queryAvailableAllowedArgs); err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 1. 解析参数并做合法性校验。
|
||||
options, err := parseQueryAvailableOptions(state, args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 2. 解析“可迭代天集合”:先解析 day/day_start/day_end,再叠加 week/day_scope/day_of_week 过滤。
|
||||
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_available_slots","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 3. 两阶段收集:
|
||||
// 3.1 先收集 strict(纯空位),保证“先空位后嵌入”的默认策略;
|
||||
// 3.2 strict 不足 limit 时,再补 embed 候选(仅在 allow_embed=true 时)。
|
||||
slots := make([]queryAvailableSlotItem, 0, options.Limit)
|
||||
seen := make(map[string]struct{}, options.Limit*2)
|
||||
|
||||
collect := func(embedAllowed bool, slotType string) {
|
||||
if len(slots) >= options.Limit {
|
||||
return
|
||||
}
|
||||
for _, day := range candidateDays {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for slotStart := 1; slotStart+options.Span-1 <= 12; slotStart++ {
|
||||
slotEnd := slotStart + options.Span - 1
|
||||
if !matchSectionRange(slotStart, slotEnd, options.ExcludedSection, options.AfterSection, options.BeforeSection, options.ExactFrom, options.ExactTo) {
|
||||
continue
|
||||
}
|
||||
|
||||
accepted := false
|
||||
if !embedAllowed {
|
||||
accepted = isStrictSlotAvailable(state, day, slotStart, slotEnd)
|
||||
} else {
|
||||
accepted = isEmbeddableSlotAvailable(state, day, slotStart, slotEnd)
|
||||
}
|
||||
if !accepted {
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%d-%d-%d", day, slotStart, slotEnd)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
slots = append(slots, queryAvailableSlotItem{
|
||||
Day: day,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SlotStart: slotStart,
|
||||
SlotEnd: slotEnd,
|
||||
SlotType: slotType,
|
||||
})
|
||||
if len(slots) >= options.Limit {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(false, "empty")
|
||||
strictCount := len(slots)
|
||||
if options.AllowEmbed && len(slots) < options.Limit {
|
||||
collect(true, "embedded_candidate")
|
||||
}
|
||||
embeddedCount := len(slots) - strictCount
|
||||
|
||||
// 4. 组装结构化返回(JSON 字符串)。
|
||||
result := queryAvailableSlotsResult{
|
||||
Tool: "query_available_slots",
|
||||
Count: len(slots),
|
||||
StrictCount: strictCount,
|
||||
EmbeddedCount: embeddedCount,
|
||||
FallbackUsed: embeddedCount > 0,
|
||||
DayScope: options.DayScope,
|
||||
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
|
||||
WeekFilter: sortedSetKeys(options.WeekSet),
|
||||
WeekFrom: options.WeekFrom,
|
||||
WeekTo: options.WeekTo,
|
||||
Span: options.Span,
|
||||
AllowEmbed: options.AllowEmbed,
|
||||
ExcludeSections: sortedSetKeys(options.ExcludedSection),
|
||||
Slots: slots,
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"query_available_slots","success":false,"error":"query encode failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// QueryTargetTasks 返回“候选任务集合”。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做筛选与结构化返回,不直接执行 move/swap;
|
||||
// 2. 默认 status=suggested,减少模型误选 existing/pending;
|
||||
// 3. 仅返回状态事实,不做“该不该移动”的语义判断。
|
||||
func QueryTargetTasks(state *ScheduleState, args map[string]any) string {
|
||||
// 0. 先做字段白名单校验:未知参数直接报错,避免静默忽略造成范围漂移。
|
||||
if err := validateToolArgsStrict(args, queryTargetAllowedArgs); err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 1. 解析参数。
|
||||
options, err := parseQueryTargetOptions(state, args)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
|
||||
// 2. 解析“可迭代天集合”过滤器。
|
||||
candidateDays, err := resolveCandidateDays(state, args, options.DayScope, options.DayOfWeekSet, options.WeekSet, options.WeekFrom, options.WeekTo)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"tool":"query_target_tasks","success":false,"error":"%s"}`, err.Error())
|
||||
}
|
||||
calendarFilterActive := isQueryTargetCalendarFilterActive(args, options)
|
||||
daySet := make(map[int]struct{}, len(candidateDays))
|
||||
for _, d := range candidateDays {
|
||||
daySet[d] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 扫描任务并按筛选条件收敛。
|
||||
items := make([]queryTargetTaskItem, 0, options.Limit)
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if !matchTaskStatus(task, options.Status) {
|
||||
continue
|
||||
}
|
||||
if len(options.TaskIDSet) > 0 {
|
||||
if _, ok := options.TaskIDSet[task.StateID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if options.Category != "" && task.Category != options.Category {
|
||||
continue
|
||||
}
|
||||
|
||||
taskSlots := make([]queryTargetTaskSlot, 0, len(task.Slots))
|
||||
for _, slot := range task.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 3.1 若存在日历过滤条件,只保留命中过滤的坐标。
|
||||
if calendarFilterActive && len(daySet) > 0 {
|
||||
if _, hit := daySet[slot.Day]; !hit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
taskSlots = append(taskSlots, queryTargetTaskSlot{
|
||||
Day: slot.Day,
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SlotStart: slot.SlotStart,
|
||||
SlotEnd: slot.SlotEnd,
|
||||
})
|
||||
}
|
||||
|
||||
// 3.2 pending 任务默认无 slots;当存在日历过滤条件时,不应混入“未知坐标任务”。
|
||||
if len(taskSlots) == 0 && calendarFilterActive {
|
||||
continue
|
||||
}
|
||||
sort.Slice(taskSlots, func(i, j int) bool {
|
||||
if taskSlots[i].Day != taskSlots[j].Day {
|
||||
return taskSlots[i].Day < taskSlots[j].Day
|
||||
}
|
||||
if taskSlots[i].SlotStart != taskSlots[j].SlotStart {
|
||||
return taskSlots[i].SlotStart < taskSlots[j].SlotStart
|
||||
}
|
||||
return taskSlots[i].SlotEnd < taskSlots[j].SlotEnd
|
||||
})
|
||||
|
||||
items = append(items, queryTargetTaskItem{
|
||||
TaskID: task.StateID,
|
||||
Name: strings.TrimSpace(task.Name),
|
||||
Category: strings.TrimSpace(task.Category),
|
||||
Status: buildTaskStatusLabel(task),
|
||||
Duration: task.Duration,
|
||||
TaskClassID: task.TaskClassID,
|
||||
Slots: taskSlots,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 稳定排序:先按最早坐标,再按 task_id。
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
leftHasSlot := len(items[i].Slots) > 0
|
||||
rightHasSlot := len(items[j].Slots) > 0
|
||||
if leftHasSlot != rightHasSlot {
|
||||
return leftHasSlot
|
||||
}
|
||||
if leftHasSlot {
|
||||
left := items[i].Slots[0]
|
||||
right := items[j].Slots[0]
|
||||
if left.Day != right.Day {
|
||||
return left.Day < right.Day
|
||||
}
|
||||
if left.SlotStart != right.SlotStart {
|
||||
return left.SlotStart < right.SlotStart
|
||||
}
|
||||
}
|
||||
return items[i].TaskID < items[j].TaskID
|
||||
})
|
||||
if len(items) > options.Limit {
|
||||
items = items[:options.Limit]
|
||||
}
|
||||
|
||||
// 5. 队列化(可选):将筛选结果自动纳入“待处理队列”。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 默认 enqueue=true,让 LLM 优先走“逐项处理”而不是一次性批量组合;
|
||||
// 2. reset_queue=true 时会清空旧队列后再入队,适合开启新一轮筛选;
|
||||
// 3. 入队仅保存 task_id,不复制任务全文,避免队列状态膨胀。
|
||||
queueInfo := (*queryTargetQueueInfo)(nil)
|
||||
enqueued := 0
|
||||
if options.Enqueue {
|
||||
taskIDs := make([]int, 0, len(items))
|
||||
for _, item := range items {
|
||||
taskIDs = append(taskIDs, item.TaskID)
|
||||
}
|
||||
if options.ResetQueue {
|
||||
enqueued = ReplaceTaskProcessingQueue(state, taskIDs)
|
||||
} else {
|
||||
enqueued = appendTaskIDsToQueue(state, taskIDs)
|
||||
}
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
queueInfo = &queryTargetQueueInfo{
|
||||
PendingCount: len(queue.PendingTaskIDs),
|
||||
CompletedCount: len(queue.CompletedTaskIDs),
|
||||
SkippedCount: len(queue.SkippedTaskIDs),
|
||||
CurrentTaskID: queue.CurrentTaskID,
|
||||
CurrentAttempt: queue.CurrentAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 结构化返回。
|
||||
result := queryTargetTasksResult{
|
||||
Tool: "query_target_tasks",
|
||||
Count: len(items),
|
||||
Status: options.Status,
|
||||
DayScope: options.DayScope,
|
||||
DayOfWeek: sortedSetKeys(options.DayOfWeekSet),
|
||||
WeekFilter: sortedSetKeys(options.WeekSet),
|
||||
WeekFrom: options.WeekFrom,
|
||||
WeekTo: options.WeekTo,
|
||||
Enqueue: options.Enqueue,
|
||||
Enqueued: enqueued,
|
||||
Queue: queueInfo,
|
||||
Items: items,
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"tool":"query_target_tasks","success":false,"error":"query encode failed"}`
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// parseQueryAvailableOptions 解析 query_available_slots 参数。
|
||||
func parseQueryAvailableOptions(state *ScheduleState, args map[string]any) (queryAvailableOptions, error) {
|
||||
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
|
||||
|
||||
allowEmbed := readBoolAnyWithDefault(args, true, "allow_embed", "allow_embedding")
|
||||
slotTypeHints := readStringSliceAny(args, "slot_types")
|
||||
if single := strings.TrimSpace(readStringAny(args, "slot_type", "")); single != "" {
|
||||
slotTypeHints = append(slotTypeHints, single)
|
||||
}
|
||||
for _, hint := range slotTypeHints {
|
||||
normalized := strings.ToLower(strings.TrimSpace(hint))
|
||||
if normalized == "pure" || normalized == "empty" || normalized == "strict" {
|
||||
allowEmbed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
span, ok := readIntAny(args, "span", "section_duration", "task_duration", "duration")
|
||||
if !ok || span <= 0 {
|
||||
span = 2
|
||||
}
|
||||
if span > 12 {
|
||||
return queryAvailableOptions{}, fmt.Errorf("span=%d 非法,必须在 1~12", span)
|
||||
}
|
||||
|
||||
limit, ok := readIntAny(args, "limit")
|
||||
if !ok || limit <= 0 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
|
||||
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
|
||||
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
|
||||
if week, hasWeek := readIntAny(args, "week"); hasWeek {
|
||||
weekFrom, weekTo = week, week
|
||||
hasWeekFrom, hasWeekTo = true, true
|
||||
}
|
||||
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
|
||||
weekFrom, weekTo = weekTo, weekFrom
|
||||
}
|
||||
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
|
||||
if !hasWeekFrom {
|
||||
weekFrom = defaultWeekFrom
|
||||
}
|
||||
if !hasWeekTo {
|
||||
weekTo = defaultWeekTo
|
||||
}
|
||||
|
||||
excluded := intSliceToSet(readIntSliceAny(args, "exclude_sections", "exclude_section"))
|
||||
afterSection, hasAfter := readIntAny(args, "after_section")
|
||||
beforeSection, hasBefore := readIntAny(args, "before_section")
|
||||
exactFrom, hasExactFrom := readIntAny(args, "section_from", "target_section_from")
|
||||
exactTo, hasExactTo := readIntAny(args, "section_to", "target_section_to")
|
||||
if hasExactFrom != hasExactTo {
|
||||
return queryAvailableOptions{}, fmt.Errorf("精确节次查询需要同时提供 section_from 和 section_to")
|
||||
}
|
||||
if hasExactFrom {
|
||||
if exactFrom < 1 || exactTo > 12 || exactFrom > exactTo {
|
||||
return queryAvailableOptions{}, fmt.Errorf("精确节次区间非法:%d-%d", exactFrom, exactTo)
|
||||
}
|
||||
span = exactTo - exactFrom + 1
|
||||
}
|
||||
|
||||
options := queryAvailableOptions{
|
||||
DayScope: scope,
|
||||
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
|
||||
WeekSet: weekSet,
|
||||
WeekFrom: weekFrom,
|
||||
WeekTo: weekTo,
|
||||
Span: span,
|
||||
Limit: limit,
|
||||
AllowEmbed: allowEmbed,
|
||||
ExcludedSection: excluded,
|
||||
}
|
||||
if hasAfter {
|
||||
options.AfterSection = &afterSection
|
||||
}
|
||||
if hasBefore {
|
||||
options.BeforeSection = &beforeSection
|
||||
}
|
||||
if hasExactFrom {
|
||||
options.ExactFrom = &exactFrom
|
||||
options.ExactTo = &exactTo
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// parseQueryTargetOptions 解析 query_target_tasks 参数。
|
||||
func parseQueryTargetOptions(state *ScheduleState, args map[string]any) (queryTargetOptions, error) {
|
||||
scope := normalizeDayScope(readStringAny(args, "day_scope", "all"))
|
||||
status := strings.ToLower(strings.TrimSpace(readStringAny(args, "status", "suggested")))
|
||||
if status == "" {
|
||||
status = "suggested"
|
||||
}
|
||||
switch status {
|
||||
case "all", "existing", "suggested", "pending":
|
||||
default:
|
||||
return queryTargetOptions{}, fmt.Errorf("status=%q 非法,仅支持 all/existing/suggested/pending", status)
|
||||
}
|
||||
|
||||
limit, ok := readIntAny(args, "limit")
|
||||
if !ok || limit <= 0 {
|
||||
limit = 16
|
||||
}
|
||||
|
||||
weekSet := intSliceToSet(readIntSliceAny(args, "week_filter", "weeks"))
|
||||
weekFrom, hasWeekFrom := readIntAny(args, "week_from", "from_week")
|
||||
weekTo, hasWeekTo := readIntAny(args, "week_to", "to_week")
|
||||
if week, hasWeek := readIntAny(args, "week"); hasWeek {
|
||||
weekFrom, weekTo = week, week
|
||||
hasWeekFrom, hasWeekTo = true, true
|
||||
}
|
||||
if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
|
||||
weekFrom, weekTo = weekTo, weekFrom
|
||||
}
|
||||
defaultWeekFrom, defaultWeekTo := inferWeekBounds(state)
|
||||
if !hasWeekFrom {
|
||||
weekFrom = defaultWeekFrom
|
||||
}
|
||||
if !hasWeekTo {
|
||||
weekTo = defaultWeekTo
|
||||
}
|
||||
|
||||
taskIDs := readIntSliceAny(args, "task_ids", "task_item_ids")
|
||||
if singleTaskID, ok := readIntAny(args, "task_id", "task_item_id"); ok {
|
||||
taskIDs = append(taskIDs, singleTaskID)
|
||||
}
|
||||
|
||||
return queryTargetOptions{
|
||||
DayScope: scope,
|
||||
DayOfWeekSet: intSliceToSet(readIntSliceAny(args, "day_of_week", "days", "day_filter")),
|
||||
WeekSet: weekSet,
|
||||
WeekFrom: weekFrom,
|
||||
WeekTo: weekTo,
|
||||
Status: status,
|
||||
Limit: limit,
|
||||
TaskIDSet: intSliceToSet(taskIDs),
|
||||
Category: strings.TrimSpace(readStringAny(args, "category", "")),
|
||||
Enqueue: readBoolAnyWithDefault(args, true, "enqueue"),
|
||||
ResetQueue: readBoolAnyWithDefault(args, false, "reset_queue"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveCandidateDays 解析并返回候选 day 列表。
|
||||
//
|
||||
// 处理规则:
|
||||
// 1. 先解析 day / day_start / day_end(互斥)形成基础集合;
|
||||
// 2. 再叠加 day_scope / day_of_week / week_* 过滤;
|
||||
// 3. 返回升序去重结果;若过滤后为空,返回空切片但不报错。
|
||||
func resolveCandidateDays(
|
||||
state *ScheduleState,
|
||||
args map[string]any,
|
||||
dayScope string,
|
||||
dayOfWeekSet map[int]struct{},
|
||||
weekSet map[int]struct{},
|
||||
weekFrom int,
|
||||
weekTo int,
|
||||
) ([]int, error) {
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("state 为空")
|
||||
}
|
||||
|
||||
day, hasDay := readIntAny(args, "day")
|
||||
dayStart, hasDayStart := readIntAny(args, "day_start")
|
||||
dayEnd, hasDayEnd := readIntAny(args, "day_end")
|
||||
if hasDay && (hasDayStart || hasDayEnd) {
|
||||
return nil, fmt.Errorf("day 与 day_start/day_end 不能同时传入")
|
||||
}
|
||||
|
||||
baseDays := make([]int, 0, state.Window.TotalDays)
|
||||
if hasDay {
|
||||
if err := validateDay(state, day); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDays = append(baseDays, day)
|
||||
} else {
|
||||
start := 1
|
||||
end := state.Window.TotalDays
|
||||
if hasDayStart {
|
||||
start = dayStart
|
||||
}
|
||||
if hasDayEnd {
|
||||
end = dayEnd
|
||||
}
|
||||
if start > end {
|
||||
return nil, fmt.Errorf("day_start=%d 不能大于 day_end=%d", start, end)
|
||||
}
|
||||
if err := validateDay(state, start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDay(state, end); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for d := start; d <= end; d++ {
|
||||
baseDays = append(baseDays, d)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]int, 0, len(baseDays))
|
||||
for _, d := range baseDays {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(d)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(dayOfWeekSet) > 0 {
|
||||
if _, hit := dayOfWeekSet[dayOfWeek]; !hit {
|
||||
continue
|
||||
}
|
||||
} else if !matchDayScope(dayOfWeek, dayScope) {
|
||||
continue
|
||||
}
|
||||
if len(weekSet) > 0 {
|
||||
if _, hit := weekSet[week]; !hit {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if week < weekFrom || week > weekTo {
|
||||
continue
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
sort.Ints(result)
|
||||
return uniqueInts(result), nil
|
||||
}
|
||||
|
||||
// matchSectionRange 判断候选节次是否满足过滤条件。
|
||||
func matchSectionRange(
|
||||
slotStart int,
|
||||
slotEnd int,
|
||||
excluded map[int]struct{},
|
||||
after *int,
|
||||
before *int,
|
||||
exactFrom *int,
|
||||
exactTo *int,
|
||||
) bool {
|
||||
if exactFrom != nil && exactTo != nil {
|
||||
if slotStart != *exactFrom || slotEnd != *exactTo {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if after != nil && slotStart <= *after {
|
||||
return false
|
||||
}
|
||||
if before != nil && slotEnd >= *before {
|
||||
return false
|
||||
}
|
||||
for section := slotStart; section <= slotEnd; section++ {
|
||||
if _, hit := excluded[section]; hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isStrictSlotAvailable 判断某段是否为“纯空位”。
|
||||
func isStrictSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if len(task.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
if task.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
if slot.Day != day {
|
||||
continue
|
||||
}
|
||||
if rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isEmbeddableSlotAvailable 判断某段是否可作为“可嵌入候选位”。
|
||||
//
|
||||
// 判定规则:
|
||||
// 1. 该段不能与不可嵌入任务冲突;
|
||||
// 2. 该段必须完全落在某个 can_embed=true 且未被占用嵌入位的宿主中;
|
||||
// 3. 若命中 can_embed 但宿主已被嵌入(embedded_by!=nil),视为不可用。
|
||||
func isEmbeddableSlotAvailable(state *ScheduleState, day int, slotStart int, slotEnd int) bool {
|
||||
hostFound := false
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if len(task.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
if task.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range task.Slots {
|
||||
if slot.Day != day {
|
||||
continue
|
||||
}
|
||||
if !rangesOverlap(slotStart, slotEnd, slot.SlotStart, slot.SlotEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !task.CanEmbed {
|
||||
return false
|
||||
}
|
||||
if task.EmbeddedBy != nil {
|
||||
return false
|
||||
}
|
||||
if slotStart >= slot.SlotStart && slotEnd <= slot.SlotEnd {
|
||||
hostFound = true
|
||||
continue
|
||||
}
|
||||
// 与可嵌入宿主部分重叠但不被完全包含,也不能作为合法嵌入位。
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hostFound
|
||||
}
|
||||
|
||||
// matchTaskStatus 判断任务是否命中 status 过滤。
|
||||
func matchTaskStatus(task ScheduleTask, status string) bool {
|
||||
switch status {
|
||||
case "all":
|
||||
return true
|
||||
case "existing":
|
||||
return IsExistingTask(task)
|
||||
case "suggested":
|
||||
return IsSuggestedTask(task)
|
||||
case "pending":
|
||||
return IsPendingTask(task)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isQueryTargetCalendarFilterActive 判断是否显式启用了日历坐标过滤。
|
||||
func isQueryTargetCalendarFilterActive(args map[string]any, options queryTargetOptions) bool {
|
||||
if _, ok := readIntAny(args, "day"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "day_start"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "day_end"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "week"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "week_from", "from_week"); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := readIntAny(args, "week_to", "to_week"); ok {
|
||||
return true
|
||||
}
|
||||
if len(readIntSliceAny(args, "week_filter", "weeks")) > 0 {
|
||||
return true
|
||||
}
|
||||
if len(options.DayOfWeekSet) > 0 {
|
||||
return true
|
||||
}
|
||||
scopeRaw := strings.TrimSpace(readStringAny(args, "day_scope"))
|
||||
return normalizeDayScope(scopeRaw) != "all" && scopeRaw != ""
|
||||
}
|
||||
|
||||
// buildTaskStatusLabel 返回任务状态标签。
|
||||
func buildTaskStatusLabel(task ScheduleTask) string {
|
||||
if IsPendingTask(task) {
|
||||
return "pending"
|
||||
}
|
||||
if IsSuggestedTask(task) {
|
||||
return "suggested"
|
||||
}
|
||||
return "existing"
|
||||
}
|
||||
|
||||
// rangesOverlap 判断两个闭区间是否重叠。
|
||||
func rangesOverlap(startA, endA, startB, endB int) bool {
|
||||
return startA <= endB && endA >= startB
|
||||
}
|
||||
|
||||
// normalizeDayScope 归一化 day_scope。
|
||||
func normalizeDayScope(scope string) string {
|
||||
scope = strings.ToLower(strings.TrimSpace(scope))
|
||||
switch scope {
|
||||
case "weekend", "workday", "all":
|
||||
return scope
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
// matchDayScope 判断 day_of_week 是否命中 day_scope。
|
||||
func matchDayScope(dayOfWeek int, scope string) bool {
|
||||
switch scope {
|
||||
case "weekend":
|
||||
return dayOfWeek == 6 || dayOfWeek == 7
|
||||
case "workday":
|
||||
return dayOfWeek >= 1 && dayOfWeek <= 5
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// inferWeekBounds 推导窗口内的最小/最大周。
|
||||
func inferWeekBounds(state *ScheduleState) (int, int) {
|
||||
if state == nil || len(state.Window.DayMapping) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
minWeek := state.Window.DayMapping[0].Week
|
||||
maxWeek := state.Window.DayMapping[0].Week
|
||||
for _, mapping := range state.Window.DayMapping {
|
||||
if mapping.Week < minWeek {
|
||||
minWeek = mapping.Week
|
||||
}
|
||||
if mapping.Week > maxWeek {
|
||||
maxWeek = mapping.Week
|
||||
}
|
||||
}
|
||||
return minWeek, maxWeek
|
||||
}
|
||||
|
||||
// readIntAny 按别名顺序读取 int 参数。
|
||||
func readIntAny(args map[string]any, keys ...string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
value, ok := ArgsInt(args, key)
|
||||
if ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// readStringAny 按别名顺序读取 string 参数。
|
||||
func readStringAny(args map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := ArgsString(args, key); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readBoolAnyWithDefault 按别名顺序读取 bool 参数。
|
||||
func readBoolAnyWithDefault(args map[string]any, defaultValue bool, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
if lower == "true" {
|
||||
return true
|
||||
}
|
||||
if lower == "false" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// readIntSliceAny 按别名顺序读取 int 列表参数。
|
||||
func readIntSliceAny(args map[string]any, keys ...string) []int {
|
||||
for _, key := range keys {
|
||||
if values, ok := ArgsIntSlice(args, key); ok {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readStringSliceAny 按别名顺序读取 string 列表参数。
|
||||
func readStringSliceAny(args map[string]any, keys ...string) []string {
|
||||
for _, key := range keys {
|
||||
raw, exists := args[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch values := raw.(type) {
|
||||
case []string:
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
trimmed := strings.TrimSpace(item)
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(values)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{trimmed}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// intSliceToSet 将 int 列表转为集合。
|
||||
func intSliceToSet(values []int) map[int]struct{} {
|
||||
if len(values) == 0 {
|
||||
return map[int]struct{}{}
|
||||
}
|
||||
set := make(map[int]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// sortedSetKeys 返回集合的升序 key 切片。
|
||||
func sortedSetKeys(set map[int]struct{}) []int {
|
||||
if len(set) == 0 {
|
||||
return []int{}
|
||||
}
|
||||
keys := make([]int, 0, len(set))
|
||||
for key := range set {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// uniqueInts 对整数切片去重并保持升序。
|
||||
func uniqueInts(values []int) []int {
|
||||
if len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
result := make([]int, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Ints(result)
|
||||
return result
|
||||
}
|
||||
249
backend/newAgent/tools/schedule/read_helpers.go
Normal file
249
backend/newAgent/tools/schedule/read_helpers.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ==================== 内部辅助类型 ====================
|
||||
|
||||
// taskOnDay 表示某个任务在某一天的一个时段占用。
|
||||
// 一个任务可能出现在多天,每天可能有多段占用(如周一1-2节 + 周三3-4节)。
|
||||
type taskOnDay struct {
|
||||
task *ScheduleTask
|
||||
slotStart int
|
||||
slotEnd int
|
||||
}
|
||||
|
||||
// freeRange 表示一段连续空闲区间。
|
||||
type freeRange struct {
|
||||
day int
|
||||
slotStart int
|
||||
slotEnd int
|
||||
}
|
||||
|
||||
// ==================== 格式化辅助函数 ====================
|
||||
|
||||
// formatSlotRange 将时段范围格式化为人类可读的字符串。
|
||||
// start == end 时输出 "3节",否则输出 "1-2节"。
|
||||
func formatSlotRange(start, end int) string {
|
||||
if start == end {
|
||||
return fmt.Sprintf("%d节", start)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d节", start, end)
|
||||
}
|
||||
|
||||
// formatTaskLabel 输出任务的简短标签,如 "[1]高等数学"。
|
||||
// LLM 交互时统一使用此格式引用任务。
|
||||
func formatTaskLabel(task ScheduleTask) string {
|
||||
return fmt.Sprintf("[%d]%s", task.StateID, task.Name)
|
||||
}
|
||||
|
||||
// formatTaskLabelWithCategory 输出带类别和锁定标记的标签。
|
||||
// 如 "[1]高等数学(课程,固定)" 或 "[2]英语(课程)"。
|
||||
// 用于 get_overview 和 list_tasks 的概要输出。
|
||||
func formatTaskLabelWithCategory(task ScheduleTask) string {
|
||||
label := fmt.Sprintf("[%d]%s(%s", task.StateID, task.Name, task.Category)
|
||||
if task.Locked {
|
||||
label += ",固定"
|
||||
}
|
||||
label += ")"
|
||||
return label
|
||||
}
|
||||
|
||||
// ==================== 占用计算辅助函数 ====================
|
||||
|
||||
// getTasksOnDay 获取某天所有“当前有落位”的任务占用列表。
|
||||
//
|
||||
// 说明:
|
||||
// 1. existing 与 suggested 都属于“有落位”;
|
||||
// 2. 旧快照里若残留 pending+Slots,也会通过 Slots 被兼容识别;
|
||||
// 3. 嵌入任务(有 EmbedHost 的)也会被返回,因为它们实际共享了该时段。
|
||||
// 返回值按 slotStart 升序排列。
|
||||
func getTasksOnDay(state *ScheduleState, day int) []taskOnDay {
|
||||
var result []taskOnDay
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if !hasSlotOnDay(t, day) {
|
||||
continue
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
if slot.Day == day {
|
||||
result = append(result, taskOnDay{
|
||||
task: t,
|
||||
slotStart: slot.SlotStart,
|
||||
slotEnd: slot.SlotEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 按 slotStart 升序排列,方便逐段输出。
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].slotStart < result[j].slotStart
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// hasSlotOnDay 判断任务是否在某天有时段占用。
|
||||
func hasSlotOnDay(task *ScheduleTask, day int) bool {
|
||||
for _, slot := range task.Slots {
|
||||
if slot.Day == day {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countDayOccupied 统计某天的已占用时段总数。
|
||||
// 每个时段(slot)是独立的节次单位,一个 TaskSlot(day=1, start=1, end=2) 占 2 个时段。
|
||||
// 嵌入任务与宿主共享时段,不重复计算。
|
||||
func countDayOccupied(state *ScheduleState, day int) int {
|
||||
occupied := 0
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 嵌入任务不重复计算占用——它和宿主共享时段。
|
||||
if t.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
if slot.Day == day {
|
||||
occupied += slot.SlotEnd - slot.SlotStart + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return occupied
|
||||
}
|
||||
|
||||
// slotOccupiedBy 查询某天某节被哪个任务占用。
|
||||
// 排除嵌入任务(EmbedHost != nil),因为嵌入任务与宿主共享时段。
|
||||
// 返回 nil 表示该节空闲。
|
||||
func slotOccupiedBy(state *ScheduleState, day, slot int) *ScheduleTask {
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 嵌入任务不视为独立占用。
|
||||
if t.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
for _, s := range t.Slots {
|
||||
if s.Day == day && slot >= s.SlotStart && slot <= s.SlotEnd {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== 空闲区间计算 ====================
|
||||
|
||||
// findFreeRangesOnDay 计算某天所有连续空闲区间。
|
||||
// 算法:
|
||||
// 1. 构建 12 个时段的占用数组(排除嵌入任务,嵌入任务共享宿主时段)
|
||||
// 2. 扫描连续空闲段
|
||||
//
|
||||
// 返回值按 slotStart 升序排列。
|
||||
func findFreeRangesOnDay(state *ScheduleState, day int) []freeRange {
|
||||
// 1. 构建占用数组:occupied[slot] = true 表示该节被占用。
|
||||
occupied := make([]bool, 13) // 下标 1-12,0 不使用
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 嵌入任务与宿主共享时段,不算独立占用。
|
||||
if t.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
if slot.Day == day {
|
||||
for s := slot.SlotStart; s <= slot.SlotEnd; s++ {
|
||||
if s >= 1 && s <= 12 {
|
||||
occupied[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 扫描连续空闲段。
|
||||
var ranges []freeRange
|
||||
start := 0
|
||||
for s := 1; s <= 12; s++ {
|
||||
if !occupied[s] {
|
||||
if start == 0 {
|
||||
start = s
|
||||
}
|
||||
} else {
|
||||
if start > 0 {
|
||||
ranges = append(ranges, freeRange{day: day, slotStart: start, slotEnd: s - 1})
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if start > 0 {
|
||||
ranges = append(ranges, freeRange{day: day, slotStart: start, slotEnd: 12})
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// getEmbeddableTasks 获取所有可嵌入时段的任务列表。
|
||||
// 条件:CanEmbed == true,用于 query_available_slots 和 get_overview 输出可嵌入位置。
|
||||
func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask {
|
||||
var result []*ScheduleTask
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if t.CanEmbed && len(t.Slots) > 0 {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 通用输出构建 ====================
|
||||
|
||||
// buildOverviewDayLine 构建某天的概况行。
|
||||
// 格式如:第1天:占6/12 — [1]高等数学(1-2节) [2]英语(3-4节)
|
||||
// 空闲天输出如:第3天:占0/12
|
||||
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("%s:占%d/12", dayLabel, occupied))
|
||||
|
||||
if len(tasks) > 0 {
|
||||
sb.WriteString(" — ")
|
||||
for i, td := range tasks {
|
||||
if i > 0 {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
label := formatTaskLabel(*td.task)
|
||||
// 如果任务可嵌入且宿主未被嵌入,标注"可嵌入"。
|
||||
suffix := ""
|
||||
if td.task.CanEmbed && td.task.EmbeddedBy == nil {
|
||||
suffix = ",可嵌入"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s%s)", label, formatSlotRange(td.slotStart, td.slotEnd), suffix))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildFreeRangeLine 格式化空闲区间行。
|
||||
// 格式如:第3天 第1-6节(6时段连续空闲)
|
||||
func buildFreeRangeLine(state *ScheduleState, r freeRange) string {
|
||||
dur := r.slotEnd - r.slotStart + 1
|
||||
return fmt.Sprintf("%s第%s(%d时段连续空闲)", formatDayLabel(state, r.day), formatSlotRange(r.slotStart, r.slotEnd), dur)
|
||||
}
|
||||
|
||||
// formatSourceName 将 source 字段转为用户可读的来源名称。
|
||||
// "event" → "课程表","task_item" → "任务"。
|
||||
// 不暴露原始 source 字段值,统一使用中文描述。
|
||||
func formatSourceName(source string) string {
|
||||
switch source {
|
||||
case "event":
|
||||
return "课程表"
|
||||
case "task_item":
|
||||
return "任务"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
842
backend/newAgent/tools/schedule/read_tools.go
Normal file
842
backend/newAgent/tools/schedule/read_tools.go
Normal file
@@ -0,0 +1,842 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ==================== 读工具:LLM 只通过这些函数感知日程状态 ====================
|
||||
// 所有读工具:
|
||||
// - 只读不改,不修改 state
|
||||
// - 返回自然语言 + 轻结构(缩进、列表),LLM 直接理解
|
||||
// - 只报当前真实状态,不做建议/推荐/假设
|
||||
// - 不暴露 source、source_id、event_type 内部字段
|
||||
|
||||
// GetOverview 获取规划窗口总览(任务视角,全量)。
|
||||
//
|
||||
// 设计约束:
|
||||
// 1. 日内“总占用”保留课程占位影响,避免 LLM 误判可用空间;
|
||||
// 2. 明细层不展开课程列表,只展开任务(非课程)清单;
|
||||
// 3. 当前按“窗口不超过 30 天”场景直接全量返回,不做结果截断。
|
||||
func GetOverview(state *ScheduleState) string {
|
||||
totalSlots := state.Window.TotalDays * 12
|
||||
|
||||
// 1. 统计总占用(含课程占位)与空闲。
|
||||
totalOccupied := 0
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if t.EmbedHost != nil {
|
||||
continue // 嵌入任务不重复计算占用
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
totalOccupied += slot.SlotEnd - slot.SlotStart + 1
|
||||
}
|
||||
}
|
||||
totalFree := totalSlots - totalOccupied
|
||||
|
||||
// 2. 统计“任务视角”状态分布,并单独统计课程条目数。
|
||||
taskExistingCount := 0
|
||||
taskSuggestedCount := 0
|
||||
taskPendingCount := 0
|
||||
courseExistingCount := 0
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if isCourseScheduleTask(task) {
|
||||
if IsExistingTask(task) {
|
||||
courseExistingCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case IsPendingTask(task):
|
||||
taskPendingCount++
|
||||
case IsSuggestedTask(task):
|
||||
taskSuggestedCount++
|
||||
case IsExistingTask(task):
|
||||
taskExistingCount++
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("规划窗口共%d天,每天12个时段,总计%d个时段。\n", state.Window.TotalDays, totalSlots))
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"当前已占用%d个,空闲%d个。课程占位条目%d个(仅用于占位统计);任务条目:已安排(existing)%d个、已预排(suggested)%d个、待安排(pending)%d个。\n",
|
||||
totalOccupied, totalFree, courseExistingCount, taskExistingCount, taskSuggestedCount, taskPendingCount,
|
||||
))
|
||||
|
||||
// 3. 逐天总览:保留课程占位计数,但只展示任务明细。
|
||||
sb.WriteString("\n每日概况:\n")
|
||||
for day := 1; day <= state.Window.TotalDays; day++ {
|
||||
sb.WriteString(buildTaskOnlyOverviewDayLine(state, day) + "\n")
|
||||
}
|
||||
|
||||
// 4. 任务清单全量展开(不截断)。
|
||||
sb.WriteString("\n任务清单(全量,已过滤课程):\n")
|
||||
sb.WriteString(buildTaskOnlyOverviewList(state))
|
||||
|
||||
// 5. 任务类约束(排课策略与限制)。
|
||||
if len(state.TaskClasses) > 0 {
|
||||
sb.WriteString("\n任务类约束(排课时请遵守):\n")
|
||||
for _, tc := range state.TaskClasses {
|
||||
strategy := formatStrategy(tc.Strategy)
|
||||
allow := "否"
|
||||
if tc.AllowFillerCourse {
|
||||
allow = "是"
|
||||
}
|
||||
line := fmt.Sprintf(" [%s] 策略=%s 总预算=%d节 允许嵌水课=%s", tc.Name, strategy, tc.TotalSlots, allow)
|
||||
if len(tc.ExcludedSlots) > 0 {
|
||||
parts := make([]string, len(tc.ExcludedSlots))
|
||||
for i, s := range tc.ExcludedSlots {
|
||||
parts[i] = fmt.Sprintf("%d", s)
|
||||
}
|
||||
line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ","))
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatStrategy 将 strategy 字段值转为中文描述。
|
||||
func formatStrategy(strategy string) string {
|
||||
switch strategy {
|
||||
case "steady":
|
||||
return "均匀分布"
|
||||
case "rapid":
|
||||
return "集中突击"
|
||||
default:
|
||||
if strategy == "" {
|
||||
return "默认"
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
}
|
||||
|
||||
// QueryRange 查看某天(或某天某段)的细粒度占用详情。
|
||||
// day 必填,slotStart/slotEnd 选填(nil 表示查整天)。
|
||||
// 整天模式按标准段(1-2, 3-4, ..., 11-12)分组输出。
|
||||
// 指定范围模式逐节输出。
|
||||
func QueryRange(state *ScheduleState, day int, slotStart, slotEnd *int) string {
|
||||
// 1. 校验 day 是否在有效范围内。
|
||||
if day < 1 || day > state.Window.TotalDays {
|
||||
return fmt.Sprintf("查询失败:第%d天不在规划窗口范围内(1-%d)。", day, state.Window.TotalDays)
|
||||
}
|
||||
|
||||
// 2. 分两种模式:整天查询 vs 指定范围查询。
|
||||
if slotStart == nil || slotEnd == nil {
|
||||
return queryRangeFullDay(state, day)
|
||||
}
|
||||
return queryRangeSpecific(state, day, *slotStart, *slotEnd)
|
||||
}
|
||||
|
||||
// queryRangeFullDay 整天查询模式:按标准段分组输出。
|
||||
// 输出格式对齐 SCHEDULE_TOOLS.md 4.2 节示例。
|
||||
func queryRangeFullDay(state *ScheduleState, day int) string {
|
||||
var sb strings.Builder
|
||||
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 {
|
||||
end := start + 1
|
||||
// 查该段的占用情况,找该段内所有占用任务。
|
||||
occupants := tasksInRange(state, day, start, end)
|
||||
if len(occupants) == 0 {
|
||||
sb.WriteString(fmt.Sprintf("第%s:空\n", formatSlotRange(start, end)))
|
||||
} else {
|
||||
desc := formatOccupants(occupants)
|
||||
sb.WriteString(fmt.Sprintf("第%s:%s\n", formatSlotRange(start, end), desc))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 附加连续空闲区摘要。
|
||||
freeRanges := findFreeRangesOnDay(state, day)
|
||||
if len(freeRanges) > 0 {
|
||||
sb.WriteString("\n连续空闲区:")
|
||||
rangeParts := make([]string, 0, len(freeRanges))
|
||||
for _, r := range freeRanges {
|
||||
dur := r.slotEnd - r.slotStart + 1
|
||||
rangeParts = append(rangeParts, fmt.Sprintf("第%s(%d时段)", formatSlotRange(r.slotStart, r.slotEnd), dur))
|
||||
}
|
||||
sb.WriteString(strings.Join(rangeParts, "、") + "\n")
|
||||
}
|
||||
|
||||
// 3. 附加可嵌入信息(仅当该天有可嵌入时段时输出)。
|
||||
embedInfo := formatEmbedInfoForDay(state, day)
|
||||
if embedInfo != "" {
|
||||
sb.WriteString("可嵌入:" + embedInfo + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// queryRangeSpecific 指定范围查询模式:逐节输出。
|
||||
func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("%s第%s:\n\n", formatDayLabel(state, day), formatSlotRange(startSlot, endSlot)))
|
||||
|
||||
total := endSlot - startSlot + 1
|
||||
freeCount := 0
|
||||
for s := startSlot; s <= endSlot; s++ {
|
||||
occupant := slotOccupiedBy(state, day, s)
|
||||
if occupant == nil {
|
||||
sb.WriteString(fmt.Sprintf("第%d节:空\n", s))
|
||||
freeCount++
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("第%d节:[%d]%s\n", s, occupant.StateID, occupant.Name))
|
||||
}
|
||||
}
|
||||
|
||||
if freeCount == total {
|
||||
sb.WriteString(fmt.Sprintf("\n该范围%d个时段全部空闲。\n", total))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n该范围%d个时段中,%d个空闲,%d个被占用。\n", total, freeCount, total-freeCount))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FindFirstFree 查找首个可用空位,并返回该日详细信息。
|
||||
//
|
||||
// 参数说明:
|
||||
// 1. duration 必填,表示需要的连续时段数;
|
||||
// 2. day 选填,指定单天搜索;
|
||||
// 3. dayStart/dayEnd 选填,指定按天范围搜索(闭区间);
|
||||
// 4. day 与 dayStart/dayEnd 互斥,避免语义冲突。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 返回“首个命中候选位 + 当日负载明细”,供 LLM 直接决策;
|
||||
// 2. 当前阶段按用户要求全量返回,不做文本截断。
|
||||
func FindFirstFree(state *ScheduleState, duration int, day, dayStart, dayEnd *int) string {
|
||||
if duration <= 0 {
|
||||
return "查询失败:duration 必须大于 0。"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Sprintf("查询失败:第%d天不在规划窗口范围内(1-%d)。", *day, state.Window.TotalDays)
|
||||
}
|
||||
days = append(days, *day)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 按天从前往后寻找“首个可直接放置”的空位。
|
||||
for _, d := range days {
|
||||
freeRanges := findFreeRangesOnDay(state, d)
|
||||
for _, r := range freeRanges {
|
||||
rDur := r.slotEnd - r.slotStart + 1
|
||||
if rDur < duration {
|
||||
continue
|
||||
}
|
||||
slotStart := r.slotStart
|
||||
slotEnd := r.slotStart + duration - 1
|
||||
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, false, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 若没有纯空位,再尝试首个可嵌入宿主时段。
|
||||
for _, d := range days {
|
||||
host, slotStart, slotEnd := findFirstEmbeddablePosition(state, d, duration)
|
||||
if host != nil {
|
||||
return buildFindFirstFreeReport(state, d, duration, slotStart, slotEnd, true, host)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 无可用位置时返回摘要,辅助 LLM 判断是否需要换天或降时长。
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("未找到满足%d个连续时段的可用位置。\n", duration))
|
||||
sb.WriteString("各天最大连续空闲区(前10天):\n")
|
||||
limit := 10
|
||||
if len(days) < limit {
|
||||
limit = len(days)
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
d := days[i]
|
||||
freeRanges := findFreeRangesOnDay(state, d)
|
||||
maxDur := 0
|
||||
for _, r := range freeRanges {
|
||||
dur := r.slotEnd - r.slotStart + 1
|
||||
if dur > maxDur {
|
||||
maxDur = dur
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s:最大连续空闲%d节\n", formatDayLabel(state, d), maxDur))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildFindFirstFreeReport 构造首个可用位的详细报告。
|
||||
func buildFindFirstFreeReport(
|
||||
state *ScheduleState,
|
||||
day int,
|
||||
duration int,
|
||||
slotStart int,
|
||||
slotEnd int,
|
||||
isEmbedded bool,
|
||||
host *ScheduleTask,
|
||||
) string {
|
||||
var sb strings.Builder
|
||||
if isEmbedded && host != nil {
|
||||
sb.WriteString(fmt.Sprintf("首个可用位置:%s(可嵌入宿主 [%d]%s)。\n",
|
||||
formatDaySlotLabel(state, day, slotStart, slotEnd), host.StateID, host.Name))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("首个可用位置:%s(可直接放置)。\n", formatDaySlotLabel(state, day, slotStart, slotEnd)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("匹配条件:需要%d个连续时段。\n", duration))
|
||||
|
||||
dayTotalOccupied := countDayOccupied(state, day)
|
||||
dayTaskOccupied := countDayTaskOccupied(state, day)
|
||||
dayCourseOccupied := dayTotalOccupied - dayTaskOccupied
|
||||
sb.WriteString(fmt.Sprintf("当日负载:总占%d/12(课程占%d/12,任务占%d/12)。\n", dayTotalOccupied, dayCourseOccupied, dayTaskOccupied))
|
||||
|
||||
sb.WriteString("当日任务明细(全量,已过滤课程):\n")
|
||||
taskEntries := collectTaskEntriesOnDay(state, day)
|
||||
if len(taskEntries) == 0 {
|
||||
sb.WriteString(" 无任务明细。\n")
|
||||
} else {
|
||||
for _, td := range taskEntries {
|
||||
sb.WriteString(fmt.Sprintf(" - [%d]%s | 状态:%s | 类别:%s | 时段:%s\n",
|
||||
td.task.StateID, td.task.Name, taskStatusLabel(*td.task), td.task.Category, formatSlotRange(td.slotStart, td.slotEnd)))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("当日连续空闲区:\n")
|
||||
freeRanges := findFreeRangesOnDay(state, day)
|
||||
if len(freeRanges) == 0 {
|
||||
sb.WriteString(" 无连续空闲区。\n")
|
||||
} else {
|
||||
for _, r := range freeRanges {
|
||||
sb.WriteString(" - " + buildFreeRangeLine(state, r) + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isCourseScheduleTask 判断任务是否属于“课程占位”。
|
||||
// 用于 get_overview 的任务视角过滤:课程只参与占位统计,不参与任务明细展开。
|
||||
func isCourseScheduleTask(task ScheduleTask) bool {
|
||||
if task.Source != "event" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(task.EventType), "course") {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(task.Category) == "课程"
|
||||
}
|
||||
|
||||
// taskStatusLabel 返回任务状态标签(existing/suggested/pending)。
|
||||
func taskStatusLabel(task ScheduleTask) string {
|
||||
switch {
|
||||
case IsPendingTask(task):
|
||||
return "pending"
|
||||
case IsSuggestedTask(task):
|
||||
return "suggested"
|
||||
default:
|
||||
return "existing"
|
||||
}
|
||||
}
|
||||
|
||||
// collectTaskEntriesOnDay 收集某天的“任务视角”明细(过滤课程)。
|
||||
func collectTaskEntriesOnDay(state *ScheduleState, day int) []taskOnDay {
|
||||
all := getTasksOnDay(state, day)
|
||||
result := make([]taskOnDay, 0, len(all))
|
||||
for _, item := range all {
|
||||
if item.task == nil {
|
||||
continue
|
||||
}
|
||||
if isCourseScheduleTask(*item.task) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// countDayTaskOccupied 统计某天任务(过滤课程)的占用时段数。
|
||||
func countDayTaskOccupied(state *ScheduleState, day int) int {
|
||||
occupied := 0
|
||||
for i := range state.Tasks {
|
||||
t := state.Tasks[i]
|
||||
if isCourseScheduleTask(t) {
|
||||
continue
|
||||
}
|
||||
if t.EmbedHost != nil {
|
||||
continue // 嵌入任务不重复计占用
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
if slot.Day == day {
|
||||
occupied += slot.SlotEnd - slot.SlotStart + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return occupied
|
||||
}
|
||||
|
||||
// buildTaskOnlyOverviewDayLine 生成某天“课程占位 + 任务明细”的摘要行。
|
||||
func buildTaskOnlyOverviewDayLine(state *ScheduleState, day int) string {
|
||||
totalOccupied := countDayOccupied(state, day)
|
||||
taskOccupied := countDayTaskOccupied(state, day)
|
||||
courseOccupied := totalOccupied - taskOccupied
|
||||
taskEntries := collectTaskEntriesOnDay(state, day)
|
||||
dayLabel := formatDayLabel(state, day)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("%s:总占%d/12(课程占%d/12,任务占%d/12)", dayLabel, totalOccupied, courseOccupied, taskOccupied))
|
||||
if len(taskEntries) == 0 {
|
||||
sb.WriteString(" — 任务:无")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
sb.WriteString(" — 任务:")
|
||||
for i, item := range taskEntries {
|
||||
if i > 0 {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s(%s,%s)",
|
||||
item.task.StateID,
|
||||
item.task.Name,
|
||||
taskStatusLabel(*item.task),
|
||||
formatSlotRange(item.slotStart, item.slotEnd),
|
||||
))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildTaskOnlyOverviewList 输出“全量任务清单”(过滤课程)。
|
||||
func buildTaskOnlyOverviewList(state *ScheduleState) string {
|
||||
tasks := make([]ScheduleTask, 0, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
task := state.Tasks[i]
|
||||
if isCourseScheduleTask(task) {
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return "无任务条目。\n"
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].StateID < tasks[j].StateID })
|
||||
|
||||
var sb strings.Builder
|
||||
for _, t := range tasks {
|
||||
classID := ""
|
||||
if t.TaskClassID > 0 {
|
||||
classID = fmt.Sprintf(" | task_class_id:%d", t.TaskClassID)
|
||||
}
|
||||
if IsPendingTask(t) {
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 需%d个连续时段\n",
|
||||
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, t.Duration))
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s | 状态:%s | 类别:%s%s | 时段:%s\n",
|
||||
t.StateID, t.Name, taskStatusLabel(t), t.Category, classID, formatTaskSlotsBriefWithState(state, t.Slots)))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// findFirstEmbeddablePosition 查找某天首个可嵌入位置。
|
||||
func findFirstEmbeddablePosition(state *ScheduleState, day, duration int) (*ScheduleTask, int, int) {
|
||||
type candidate struct {
|
||||
task *ScheduleTask
|
||||
slotStart int
|
||||
slotEnd int
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
|
||||
for _, host := range getEmbeddableTasks(state) {
|
||||
if host == nil || host.EmbeddedBy != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range host.Slots {
|
||||
if slot.Day != day {
|
||||
continue
|
||||
}
|
||||
span := slot.SlotEnd - slot.SlotStart + 1
|
||||
if span < duration {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate{
|
||||
task: host,
|
||||
slotStart: slot.SlotStart,
|
||||
slotEnd: slot.SlotStart + duration - 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return nil, 0, 0
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool { return candidates[i].slotStart < candidates[j].slotStart })
|
||||
best := candidates[0]
|
||||
return best.task, best.slotStart, best.slotEnd
|
||||
}
|
||||
|
||||
// ListTasks 列出任务清单,可按类别和状态过滤。
|
||||
// category 选填(nil 不过滤),status 选填(nil 默认 "all")。
|
||||
// 输出按状态分组:已安排 -> 已预排 -> 待安排,组内按 stateID 升序。
|
||||
func ListTasks(state *ScheduleState, category, status *string) string {
|
||||
// 1. 确定过滤状态。
|
||||
statusFilter := "all"
|
||||
if status != nil {
|
||||
statusFilter = *status
|
||||
}
|
||||
statusFilter = strings.ToLower(strings.TrimSpace(statusFilter))
|
||||
if statusFilter == "" {
|
||||
statusFilter = "all"
|
||||
}
|
||||
if err := validateListTasksStatus(statusFilter); err != nil {
|
||||
return fmt.Sprintf("查询失败:%s", err.Error())
|
||||
}
|
||||
categoryFilter := ""
|
||||
if category != nil {
|
||||
categoryFilter = strings.TrimSpace(*category)
|
||||
}
|
||||
hasCategoryFilter := categoryFilter != ""
|
||||
|
||||
// 2. 过滤 + 分组。
|
||||
var existingTasks, suggestedTasks, pendingTasks []ScheduleTask
|
||||
for i := range state.Tasks {
|
||||
t := state.Tasks[i]
|
||||
// 类别过滤。
|
||||
if hasCategoryFilter && t.Category != categoryFilter {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case IsPendingTask(t):
|
||||
if statusFilter != "all" && statusFilter != "pending" {
|
||||
continue
|
||||
}
|
||||
pendingTasks = append(pendingTasks, t)
|
||||
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. 纯待安排模式:只输出待安排任务。
|
||||
if statusFilter == "pending" {
|
||||
if len(pendingTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatPendingList(pendingTasks)
|
||||
}
|
||||
|
||||
// 5. 纯已预排模式:只输出已预排任务。
|
||||
if statusFilter == "suggested" {
|
||||
if len(suggestedTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatSuggestedList(state, suggestedTasks)
|
||||
}
|
||||
|
||||
// 6. 纯已安排模式:只输出已安排任务。
|
||||
if statusFilter == "existing" {
|
||||
if len(existingTasks) == 0 {
|
||||
return formatListTasksEmptyResult(statusFilter, categoryFilter)
|
||||
}
|
||||
return formatExistingList(state, existingTasks)
|
||||
}
|
||||
|
||||
// 7. 全部模式:统计 + 分组输出。
|
||||
total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("共%d个任务,已安排(existing)%d个,已预排(suggested)%d个,待安排(pending)%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks)))
|
||||
|
||||
if len(existingTasks) > 0 {
|
||||
sb.WriteString("\n已安排(existing):\n")
|
||||
sb.WriteString(formatExistingList(state, existingTasks))
|
||||
}
|
||||
if len(suggestedTasks) > 0 {
|
||||
sb.WriteString("\n已预排(suggested):\n")
|
||||
sb.WriteString(formatSuggestedList(state, suggestedTasks))
|
||||
}
|
||||
if len(pendingTasks) > 0 {
|
||||
sb.WriteString("\n待安排(pending):\n")
|
||||
sb.WriteString(formatPendingList(pendingTasks))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatListTasksEmptyResult 统一构造 list_tasks 空结果文案。
|
||||
//
|
||||
// 设计意图:
|
||||
// 1. 明确告诉模型“为什么为空”,避免把空字符串误解为工具异常或上下文缺失;
|
||||
// 2. 对常见误用 category=ID 列表给出直接纠偏提示,减少死循环重试。
|
||||
func formatListTasksEmptyResult(statusFilter, categoryFilter string) string {
|
||||
statusLabel := map[string]string{
|
||||
"all": "任意状态",
|
||||
"existing": "已安排(existing)",
|
||||
"suggested": "已预排(suggested)",
|
||||
"pending": "待安排(pending)",
|
||||
}
|
||||
target := statusLabel[statusFilter]
|
||||
if target == "" {
|
||||
target = statusFilter
|
||||
}
|
||||
|
||||
if strings.TrimSpace(categoryFilter) == "" {
|
||||
return fmt.Sprintf("查询结果为空:当前没有%s任务。", target)
|
||||
}
|
||||
if looksLikeTaskClassIDList(categoryFilter) {
|
||||
return fmt.Sprintf("查询结果为空:category=%q 未匹配到任务。category 参数按任务类名称匹配,不支持 task_class_ids 列表。", categoryFilter)
|
||||
}
|
||||
return fmt.Sprintf("查询结果为空:category=%q 下没有%s任务。", categoryFilter, target)
|
||||
}
|
||||
|
||||
// looksLikeTaskClassIDList 判断 category 文本是否像“逗号分隔的数字 ID 列表”。
|
||||
func looksLikeTaskClassIDList(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validateListTasksStatus 校验 list_tasks.status 的输入值。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责拦截非法 status,避免“静默返回 0 条”误导模型;
|
||||
// 2. 不负责自动拆分或容错纠偏(如 existing,suggested),统一要求调用方改成合法单值。
|
||||
func validateListTasksStatus(status string) error {
|
||||
// 1. status 已在调用方归一化为小写并去空格。
|
||||
// 2. 合法值仅允许 all / existing / suggested / pending。
|
||||
switch status {
|
||||
case "all", "existing", "suggested", "pending":
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. 对最常见误用给出明确修复建议,避免模型继续循环错误调用。
|
||||
if strings.Contains(status, ",") {
|
||||
return fmt.Errorf("status 只支持单值 all/existing/suggested/pending,不支持 \"%s\"。如需同时查看 existing+suggested,请使用 all", status)
|
||||
}
|
||||
return fmt.Errorf("status=%q 非法,仅支持 all/existing/suggested/pending", status)
|
||||
}
|
||||
|
||||
// GetTaskInfo 查询单个任务的详细信息。
|
||||
// taskID 必填,为 state 内的 state_id。
|
||||
// 不存在时返回错误信息字符串。
|
||||
func GetTaskInfo(state *ScheduleState, taskID int) string {
|
||||
task := state.TaskByStateID(taskID)
|
||||
if task == nil {
|
||||
return fmt.Sprintf("查询失败:任务ID %d 不存在。", taskID)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("[%d]%s\n", task.StateID, task.Name))
|
||||
|
||||
// 1. 类别、状态、来源。
|
||||
statusLabel := "已安排(existing)"
|
||||
if IsPendingTask(*task) {
|
||||
statusLabel = "待安排(pending)"
|
||||
} else if IsSuggestedTask(*task) {
|
||||
statusLabel = "已预排(suggested)"
|
||||
} else if task.Locked {
|
||||
statusLabel = "已安排(existing,固定)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("类别:%s | 状态:%s\n", task.Category, statusLabel))
|
||||
sb.WriteString(fmt.Sprintf("来源:%s\n", formatSourceName(task.Source)))
|
||||
|
||||
// 2. 可嵌入信息(仅 can_embed 任务显示)。
|
||||
if task.CanEmbed {
|
||||
sb.WriteString("可嵌入:是(允许在此时段嵌入其他任务)\n")
|
||||
}
|
||||
|
||||
// 3. 占用时段。
|
||||
if len(task.Slots) > 0 {
|
||||
sb.WriteString("占用时段:\n")
|
||||
for _, slot := range task.Slots {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd)))
|
||||
}
|
||||
}
|
||||
|
||||
// 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. 嵌入关系信息。
|
||||
if task.CanEmbed {
|
||||
if task.EmbeddedBy != nil {
|
||||
guest := state.TaskByStateID(*task.EmbeddedBy)
|
||||
if guest != nil {
|
||||
sb.WriteString(fmt.Sprintf("当前嵌入任务:[%d]%s\n", guest.StateID, guest.Name))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("当前嵌入任务:无\n")
|
||||
}
|
||||
}
|
||||
if task.EmbedHost != nil {
|
||||
host := state.TaskByStateID(*task.EmbedHost)
|
||||
if host != nil {
|
||||
sb.WriteString(fmt.Sprintf("嵌入宿主:[%d]%s\n", host.StateID, host.Name))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ==================== 内部格式化函数 ====================
|
||||
|
||||
// tasksInRange 获取某天指定时段范围内的占用任务列表。
|
||||
// 返回在该范围内有占用的所有任务(去重,按 slotStart 排序)。
|
||||
func tasksInRange(state *ScheduleState, day, start, end int) []taskOnDay {
|
||||
tasks := getTasksOnDay(state, day)
|
||||
var result []taskOnDay
|
||||
for _, td := range tasks {
|
||||
// 判断是否有交集:任务的 [slotStart, slotEnd] 与查询范围 [start, end] 有重叠。
|
||||
if td.slotStart <= end && td.slotEnd >= start {
|
||||
result = append(result, td)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatOccupants 格式化占用任务列表为紧凑描述。
|
||||
// 如 "[1]高等数学(固定)" 或 "[6]线代"
|
||||
func formatOccupants(occupants []taskOnDay) string {
|
||||
parts := make([]string, 0, len(occupants))
|
||||
for _, o := range occupants {
|
||||
label := formatTaskLabel(*o.task)
|
||||
if o.task.Locked {
|
||||
parts = append(parts, label+"(固定)")
|
||||
} else if o.task.CanEmbed {
|
||||
parts = append(parts, label+"(可嵌入)")
|
||||
} else {
|
||||
parts = append(parts, label)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// formatEmbedInfoForDay 格式化某天的可嵌入信息。
|
||||
// 返回空字符串表示该天没有可嵌入时段。
|
||||
func formatEmbedInfoForDay(state *ScheduleState, day int) string {
|
||||
var parts []string
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if !t.CanEmbed {
|
||||
continue
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
if slot.Day != day {
|
||||
continue
|
||||
}
|
||||
label := formatTaskLabel(*t)
|
||||
if t.Locked {
|
||||
parts = append(parts, fmt.Sprintf("第%s已有%s(固定,不可嵌入)", formatSlotRange(slot.SlotStart, slot.SlotEnd), label))
|
||||
} else {
|
||||
embedStatus := "可嵌入"
|
||||
if t.EmbeddedBy != nil {
|
||||
guest := state.TaskByStateID(*t.EmbeddedBy)
|
||||
if guest != nil {
|
||||
embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name)
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("第%s已有%s(%s)", formatSlotRange(slot.SlotStart, slot.SlotEnd), label, embedStatus))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// formatExistingList 格式化已安排任务列表。
|
||||
// 格式如: [1]高等数学(课程,固定) — 第1天(1-2节) 第4天(1-2节)
|
||||
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("%s(%s)", formatDayLabel(state, slot.Day), formatSlotRange(slot.SlotStart, slot.SlotEnd)))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s — %s\n", label, strings.Join(slotParts, " ")))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatSuggestedList 格式化已预排任务列表。
|
||||
// 格式如:[3]复习线代 — 已预排至 第2天第3-4节,类别:学习
|
||||
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, formatTaskSlotsBriefWithState(state, t.Slots), t.Category))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatPendingList 格式化待安排任务列表。
|
||||
// 格式如:[3]复习线代 — 需3个连续时段,类别:学习
|
||||
func formatPendingList(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 — 需%d个连续时段,类别:%s\n", t.StateID, t.Name, t.Duration, t.Category))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
177
backend/newAgent/tools/schedule/runtime_queue.go
Normal file
177
backend/newAgent/tools/schedule/runtime_queue.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package schedule
|
||||
|
||||
// TaskProcessingQueue 表示 execute 阶段的“逐项处理队列”运行态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. PendingTaskIDs:尚未开始处理的候选任务;
|
||||
// 2. CurrentTaskID:当前正在处理的队首任务(0 表示暂无);
|
||||
// 3. CompletedTaskIDs / SkippedTaskIDs:本轮处理结果归档;
|
||||
// 4. LastError:最近一次 apply 失败的原因,供 LLM 下一轮决策参考。
|
||||
type TaskProcessingQueue struct {
|
||||
PendingTaskIDs []int `json:"pending_task_ids,omitempty"`
|
||||
CurrentTaskID int `json:"current_task_id,omitempty"`
|
||||
CurrentAttempts int `json:"current_attempts,omitempty"`
|
||||
CompletedTaskIDs []int `json:"completed_task_ids,omitempty"`
|
||||
SkippedTaskIDs []int `json:"skipped_task_ids,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
// ensureTaskProcessingQueue 确保 state 上有可用队列容器。
|
||||
func ensureTaskProcessingQueue(state *ScheduleState) *TaskProcessingQueue {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
if state.RuntimeQueue == nil {
|
||||
state.RuntimeQueue = &TaskProcessingQueue{}
|
||||
}
|
||||
return state.RuntimeQueue
|
||||
}
|
||||
|
||||
// ResetTaskProcessingQueue 清空本轮临时队列,供“新一轮执行开始”时调用。
|
||||
func ResetTaskProcessingQueue(state *ScheduleState) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
state.RuntimeQueue = nil
|
||||
}
|
||||
|
||||
// ReplaceTaskProcessingQueue 用新的任务 ID 列表覆盖队列。
|
||||
//
|
||||
// 步骤化说明:
|
||||
// 1. 先重置队列,避免上一次处理结果残留;
|
||||
// 2. 对输入任务 ID 去重,防止 LLM 重复筛选造成同任务重复入队;
|
||||
// 3. 不自动弹出当前任务,保持“显式 queue_pop_head 才开始处理”的流程约束。
|
||||
func ReplaceTaskProcessingQueue(state *ScheduleState, taskIDs []int) int {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil {
|
||||
return 0
|
||||
}
|
||||
queue.PendingTaskIDs = nil
|
||||
queue.CurrentTaskID = 0
|
||||
queue.CurrentAttempts = 0
|
||||
queue.CompletedTaskIDs = nil
|
||||
queue.SkippedTaskIDs = nil
|
||||
queue.LastError = ""
|
||||
return appendTaskIDsToQueue(state, taskIDs)
|
||||
}
|
||||
|
||||
// appendTaskIDsToQueue 将任务追加到队列尾部并做去重,返回本次实际入队数量。
|
||||
//
|
||||
// 去重规则:
|
||||
// 1. 与当前正在处理的任务去重;
|
||||
// 2. 与 pending / completed / skipped 去重;
|
||||
// 3. task_id<=0 直接忽略,避免无效数据污染队列。
|
||||
func appendTaskIDsToQueue(state *ScheduleState, taskIDs []int) int {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || len(taskIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
exists := make(map[int]struct{}, len(queue.PendingTaskIDs)+len(queue.CompletedTaskIDs)+len(queue.SkippedTaskIDs)+1)
|
||||
if queue.CurrentTaskID > 0 {
|
||||
exists[queue.CurrentTaskID] = struct{}{}
|
||||
}
|
||||
for _, id := range queue.PendingTaskIDs {
|
||||
exists[id] = struct{}{}
|
||||
}
|
||||
for _, id := range queue.CompletedTaskIDs {
|
||||
exists[id] = struct{}{}
|
||||
}
|
||||
for _, id := range queue.SkippedTaskIDs {
|
||||
exists[id] = struct{}{}
|
||||
}
|
||||
|
||||
added := 0
|
||||
for _, id := range taskIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := exists[id]; ok {
|
||||
continue
|
||||
}
|
||||
queue.PendingTaskIDs = append(queue.PendingTaskIDs, id)
|
||||
exists[id] = struct{}{}
|
||||
added++
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
// popOrGetCurrentTaskID 返回当前可处理任务。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 若已有 CurrentTaskID,直接复用(保证 apply/skip 前不切换对象);
|
||||
// 2. 若 current 为空且 pending 非空,则弹出队首并设为 current;
|
||||
// 3. 若队列为空,返回 0。
|
||||
func popOrGetCurrentTaskID(state *ScheduleState) int {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil {
|
||||
return 0
|
||||
}
|
||||
if queue.CurrentTaskID > 0 {
|
||||
return queue.CurrentTaskID
|
||||
}
|
||||
if len(queue.PendingTaskIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
queue.CurrentTaskID = queue.PendingTaskIDs[0]
|
||||
queue.PendingTaskIDs = queue.PendingTaskIDs[1:]
|
||||
queue.CurrentAttempts = 0
|
||||
queue.LastError = ""
|
||||
return queue.CurrentTaskID
|
||||
}
|
||||
|
||||
// markCurrentTaskCompleted 将 current 任务标记为完成并清空 current。
|
||||
func markCurrentTaskCompleted(state *ScheduleState) {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || queue.CurrentTaskID <= 0 {
|
||||
return
|
||||
}
|
||||
queue.CompletedTaskIDs = append(queue.CompletedTaskIDs, queue.CurrentTaskID)
|
||||
queue.CurrentTaskID = 0
|
||||
queue.CurrentAttempts = 0
|
||||
queue.LastError = ""
|
||||
}
|
||||
|
||||
// markCurrentTaskSkipped 将 current 任务标记为跳过并清空 current。
|
||||
func markCurrentTaskSkipped(state *ScheduleState) {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || queue.CurrentTaskID <= 0 {
|
||||
return
|
||||
}
|
||||
queue.SkippedTaskIDs = append(queue.SkippedTaskIDs, queue.CurrentTaskID)
|
||||
queue.CurrentTaskID = 0
|
||||
queue.CurrentAttempts = 0
|
||||
queue.LastError = ""
|
||||
}
|
||||
|
||||
// bumpCurrentTaskAttempt 记录 current 任务一次失败尝试。
|
||||
func bumpCurrentTaskAttempt(state *ScheduleState, errText string) {
|
||||
queue := ensureTaskProcessingQueue(state)
|
||||
if queue == nil || queue.CurrentTaskID <= 0 {
|
||||
return
|
||||
}
|
||||
queue.CurrentAttempts++
|
||||
queue.LastError = errText
|
||||
}
|
||||
|
||||
// cloneTaskProcessingQueue 深拷贝 RuntimeQueue。
|
||||
func cloneTaskProcessingQueue(src *TaskProcessingQueue) *TaskProcessingQueue {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := &TaskProcessingQueue{
|
||||
CurrentTaskID: src.CurrentTaskID,
|
||||
CurrentAttempts: src.CurrentAttempts,
|
||||
LastError: src.LastError,
|
||||
}
|
||||
if len(src.PendingTaskIDs) > 0 {
|
||||
dst.PendingTaskIDs = append([]int(nil), src.PendingTaskIDs...)
|
||||
}
|
||||
if len(src.CompletedTaskIDs) > 0 {
|
||||
dst.CompletedTaskIDs = append([]int(nil), src.CompletedTaskIDs...)
|
||||
}
|
||||
if len(src.SkippedTaskIDs) > 0 {
|
||||
dst.SkippedTaskIDs = append([]int(nil), src.SkippedTaskIDs...)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
143
backend/newAgent/tools/schedule/state.go
Normal file
143
backend/newAgent/tools/schedule/state.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package schedule
|
||||
|
||||
// DayMapping maps a day_index to a real (week, day_of_week) coordinate.
|
||||
type DayMapping struct {
|
||||
DayIndex int `json:"day_index"`
|
||||
Week int `json:"week"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
}
|
||||
|
||||
// ScheduleWindow defines the planning window.
|
||||
type ScheduleWindow struct {
|
||||
TotalDays int `json:"total_days"`
|
||||
DayMapping []DayMapping `json:"day_mapping"`
|
||||
}
|
||||
|
||||
// TaskSlot is a compressed time slot using day_index and section range.
|
||||
type TaskSlot struct {
|
||||
Day int `json:"day"`
|
||||
SlotStart int `json:"slot_start"`
|
||||
SlotEnd int `json:"slot_end"`
|
||||
}
|
||||
|
||||
// TaskClassMeta 是任务类级别的调度约束,供 LLM 在排课时参考。
|
||||
// 只记录影响排课决策的字段,不暴露数据库内部细节。
|
||||
type TaskClassMeta struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Strategy string `json:"strategy"` // "steady"=均匀分布 | "rapid"=集中突击
|
||||
TotalSlots int `json:"total_slots"` // 该任务类总时段预算
|
||||
AllowFillerCourse bool `json:"allow_filler_course"` // 是否允许嵌入水课时段
|
||||
ExcludedSlots []int `json:"excluded_slots"` // 排除的半天时段索引(空=无限制)
|
||||
StartDate string `json:"start_date,omitempty"` // 排程起始日期(YYYY-MM-DD)
|
||||
EndDate string `json:"end_date,omitempty"` // 排程截止日期(YYYY-MM-DD)
|
||||
}
|
||||
|
||||
// ScheduleTask is a unified task representation in the tool state.
|
||||
// It merges existing schedules (from schedule_events) and pending tasks (from task_items)
|
||||
// into one flat list that the tool layer operates on.
|
||||
type ScheduleTask struct {
|
||||
StateID int `json:"state_id"`
|
||||
Source string `json:"source"` // "event" | "task_item"
|
||||
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" | "suggested" | "pending"
|
||||
Locked bool `json:"locked"`
|
||||
|
||||
// Existing / suggested task: compressed slot ranges. Pending task: nil until placed.
|
||||
Slots []TaskSlot `json:"slots,omitempty"`
|
||||
// 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"`
|
||||
// source=task_item only: TaskClass.ID for category lookup (internal alias).
|
||||
CategoryID int `json:"category_id,omitempty"`
|
||||
// source=event only: whether this slot allows embedding other tasks.
|
||||
CanEmbed bool `json:"can_embed,omitempty"`
|
||||
|
||||
// Embed relationships (resolved after all tasks are loaded).
|
||||
EmbeddedBy *int `json:"embedded_by,omitempty"` // host: which state_id is embedded into me
|
||||
EmbedHost *int `json:"embed_host,omitempty"` // guest: which state_id's slot I'm embedded into
|
||||
|
||||
// Internal: not exposed to LLM, used for flush/diff logic.
|
||||
EventType string `json:"event_type,omitempty"` // "course" | "task" (source=event only)
|
||||
}
|
||||
|
||||
// ScheduleState is the full tool operation state.
|
||||
type ScheduleState struct {
|
||||
Window ScheduleWindow `json:"window"`
|
||||
Tasks []ScheduleTask `json:"tasks"`
|
||||
TaskClasses []TaskClassMeta `json:"task_classes,omitempty"` // 任务类约束元数据,供 LLM 排课参考
|
||||
// RuntimeQueue 是“本轮 execute 微调”的临时待处理队列。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承载 LLM 队列化微调时的运行态(待处理/当前处理/已完成/已跳过);
|
||||
// 2. 只用于 newAgent 运行期,不参与数据库持久化;
|
||||
// 3. 支持随 AgentStateSnapshot 一起快照,便于断线恢复后继续处理队首任务。
|
||||
RuntimeQueue *TaskProcessingQueue `json:"runtime_queue,omitempty"`
|
||||
}
|
||||
|
||||
// DayToWeekDay converts day_index to (week, day_of_week).
|
||||
func (s *ScheduleState) DayToWeekDay(day int) (week, dayOfWeek int, ok bool) {
|
||||
for _, m := range s.Window.DayMapping {
|
||||
if m.DayIndex == day {
|
||||
return m.Week, m.DayOfWeek, true
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// WeekDayToDay converts (week, day_of_week) to day_index.
|
||||
func (s *ScheduleState) WeekDayToDay(week, dayOfWeek int) (day int, ok bool) {
|
||||
for _, m := range s.Window.DayMapping {
|
||||
if m.Week == week && m.DayOfWeek == dayOfWeek {
|
||||
return m.DayIndex, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// TaskByStateID finds a task by state_id. Returns nil if not found.
|
||||
func (s *ScheduleState) TaskByStateID(stateID int) *ScheduleTask {
|
||||
for i := range s.Tasks {
|
||||
if s.Tasks[i].StateID == stateID {
|
||||
return &s.Tasks[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the ScheduleState.
|
||||
func (s *ScheduleState) Clone() *ScheduleState {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &ScheduleState{
|
||||
Window: ScheduleWindow{
|
||||
TotalDays: s.Window.TotalDays,
|
||||
DayMapping: make([]DayMapping, len(s.Window.DayMapping)),
|
||||
},
|
||||
Tasks: make([]ScheduleTask, len(s.Tasks)),
|
||||
TaskClasses: make([]TaskClassMeta, len(s.TaskClasses)),
|
||||
}
|
||||
copy(clone.Window.DayMapping, s.Window.DayMapping)
|
||||
copy(clone.TaskClasses, s.TaskClasses)
|
||||
for i, t := range s.Tasks {
|
||||
clone.Tasks[i] = t
|
||||
if t.Slots != nil {
|
||||
clone.Tasks[i].Slots = make([]TaskSlot, len(t.Slots))
|
||||
copy(clone.Tasks[i].Slots, t.Slots)
|
||||
}
|
||||
if t.EmbeddedBy != nil {
|
||||
v := *t.EmbeddedBy
|
||||
clone.Tasks[i].EmbeddedBy = &v
|
||||
}
|
||||
if t.EmbedHost != nil {
|
||||
v := *t.EmbedHost
|
||||
clone.Tasks[i].EmbedHost = &v
|
||||
}
|
||||
}
|
||||
clone.RuntimeQueue = cloneTaskProcessingQueue(s.RuntimeQueue)
|
||||
return clone
|
||||
}
|
||||
118
backend/newAgent/tools/schedule/status.go
Normal file
118
backend/newAgent/tools/schedule/status.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package schedule
|
||||
|
||||
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
|
||||
}
|
||||
256
backend/newAgent/tools/schedule/write_helpers.go
Normal file
256
backend/newAgent/tools/schedule/write_helpers.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ==================== 写工具专用辅助函数 ====================
|
||||
|
||||
// ==================== 校验函数 ====================
|
||||
|
||||
// validateDay 校验 day 是否在规划窗口范围内。
|
||||
func validateDay(state *ScheduleState, day int) error {
|
||||
if day < 1 || day > state.Window.TotalDays {
|
||||
return fmt.Errorf("第%d天不在规划窗口范围内(1-%d)", day, state.Window.TotalDays)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSlotRange 校验时段范围是否合法(1-12,start <= end)。
|
||||
func validateSlotRange(start, end int) error {
|
||||
if start < 1 {
|
||||
return fmt.Errorf("起始时段 %d 不能小于1", start)
|
||||
}
|
||||
if end > 12 {
|
||||
return fmt.Errorf("结束时段 %d 不能大于12", end)
|
||||
}
|
||||
if start > end {
|
||||
return fmt.Errorf("起始时段 %d 不能大于结束时段 %d", start, end)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkLocked 检查任务是否被锁定。锁定任务不可移动/交换/移除。
|
||||
func checkLocked(task ScheduleTask) error {
|
||||
if task.Locked {
|
||||
return fmt.Errorf("[%d]%s 是固定课程,不可操作", task.StateID, task.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== 冲突检测 ====================
|
||||
|
||||
// findConflict 查找指定范围 [start, end] 内是否有冲突。
|
||||
// 排除 excludeStateIDs 中的任务(用于 move/swap 排除自身旧位置)。
|
||||
// 可嵌入宿主(can_embed=true)不算冲突——嵌入场景由 place 单独处理。
|
||||
// 返回第一个冲突任务,无冲突返回 nil。
|
||||
func findConflict(state *ScheduleState, day, start, end int, excludeStateIDs ...int) *ScheduleTask {
|
||||
// 构建排除集合
|
||||
exclude := make(map[int]bool, len(excludeStateIDs))
|
||||
for _, id := range excludeStateIDs {
|
||||
exclude[id] = true
|
||||
}
|
||||
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 排除指定任务
|
||||
if exclude[t.StateID] {
|
||||
continue
|
||||
}
|
||||
// 可嵌入宿主不算冲突
|
||||
if t.CanEmbed {
|
||||
continue
|
||||
}
|
||||
// 嵌入任务与宿主共享时段,不算独立冲突
|
||||
if t.EmbedHost != nil {
|
||||
continue
|
||||
}
|
||||
// 只检查已安排的任务
|
||||
if len(t.Slots) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
if slot.Day == day {
|
||||
// 检查范围是否有交集:[start,end] ∩ [slot.SlotStart,slot.SlotEnd]
|
||||
if start <= slot.SlotEnd && end >= slot.SlotStart {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findEmbedHost 查找指定范围 [start, end] 内是否有可嵌入的宿主。
|
||||
// 条件:can_embed=true 且未被嵌入(embedded_by == nil)。
|
||||
// 返回第一个匹配的宿主,无匹配返回 nil。
|
||||
func findEmbedHost(state *ScheduleState, day, start, end int) *ScheduleTask {
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if !t.CanEmbed || t.EmbeddedBy != nil {
|
||||
continue
|
||||
}
|
||||
for _, slot := range t.Slots {
|
||||
if slot.Day == day {
|
||||
// 完全包含在宿主时段内才能嵌入
|
||||
if start >= slot.SlotStart && end <= slot.SlotEnd {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== 计算辅助 ====================
|
||||
|
||||
// taskDuration 计算任务所有 Slots 的总时段数。
|
||||
// 如 Slots = [{1,1,2}, {3,1,2}] → 总时长 = 2+2 = 4。
|
||||
// 用于 swap 时比较两个任务的时长是否一致。
|
||||
func taskDuration(task ScheduleTask) int {
|
||||
total := 0
|
||||
for _, slot := range task.Slots {
|
||||
total += slot.SlotEnd - slot.SlotStart + 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// countPending 统计当前 state 中“真实待安排”任务数量。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 这里只统计 pending 且无 Slots 的任务;
|
||||
// 2. 旧快照里 pending+Slots 会被 suggested 兼容层吸收,不再算入待安排。
|
||||
func countPending(state *ScheduleState) int {
|
||||
count := 0
|
||||
for i := range state.Tasks {
|
||||
if IsPendingTask(state.Tasks[i]) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// ==================== 任务时段辅助 ====================
|
||||
|
||||
// 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, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// collectAffectedDays 从旧位置和新位置中收集所有涉及的天(去重排序)。
|
||||
func collectAffectedDays(oldSlots, newSlots []TaskSlot) []int {
|
||||
days := make(map[int]bool)
|
||||
for _, s := range oldSlots {
|
||||
days[s.Day] = true
|
||||
}
|
||||
for _, s := range newSlots {
|
||||
days[s.Day] = true
|
||||
}
|
||||
return sortedKeys(days)
|
||||
}
|
||||
|
||||
// collectAffectedDaysFromSlots 从单个 slot 列表中收集涉及的天。
|
||||
func collectAffectedDaysFromSlots(slots []TaskSlot) []int {
|
||||
days := make(map[int]bool)
|
||||
for _, s := range slots {
|
||||
days[s.Day] = true
|
||||
}
|
||||
return sortedKeys(days)
|
||||
}
|
||||
|
||||
// sortedKeys 将 map 的 key 排序后返回。
|
||||
func sortedKeys(m map[int]bool) []int {
|
||||
keys := make([]int, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// uniqueSorted 对 int 切片去重并排序。
|
||||
func uniqueSorted(s []int) []int {
|
||||
seen := make(map[int]bool)
|
||||
result := make([]int, 0, len(s))
|
||||
for _, v := range s {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
sort.Ints(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 输出格式化 ====================
|
||||
|
||||
// formatDayOccupancy 格式化某天的占用摘要。
|
||||
// 如 "第5天当前占用:[3]复习线代(1-3节),占用3/12。"
|
||||
// 如 "第4天当前占用:0/12。"(空天)
|
||||
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("%s当前占用:0/12。", dayLabel)
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(tasks))
|
||||
for _, td := range tasks {
|
||||
label := formatTaskLabel(*td.task)
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)", label, formatSlotRange(td.slotStart, td.slotEnd)))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s当前占用:%s,占用%d/12。", dayLabel, strings.Join(parts, " "), occupied)
|
||||
}
|
||||
|
||||
// formatFreeHint 格式化某天的空闲时段提示。
|
||||
// 如 "空闲时段:第5-12节。"
|
||||
// 无空闲时返回空字符串。
|
||||
func formatFreeHint(state *ScheduleState, day int) string {
|
||||
ranges := findFreeRangesOnDay(state, day)
|
||||
if len(ranges) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
parts = append(parts, formatSlotRange(r.slotStart, r.slotEnd))
|
||||
}
|
||||
return fmt.Sprintf("空闲时段:%s。", strings.Join(parts, "、"))
|
||||
}
|
||||
424
backend/newAgent/tools/schedule/write_tools.go
Normal file
424
backend/newAgent/tools/schedule/write_tools.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package schedule
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
const (
|
||||
// maxBatchMoveSize 是 batch_move 的安全上限。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 旧链路中 batch_move 容易因组合冲突导致“整批回滚 + 连续重试”;
|
||||
// 2. 先把批量规模限制在 2,作为止血策略,降低一次决策的冲突面;
|
||||
// 3. 更大规模的调整应优先走队列化逐项处理(queue_pop_head + queue_apply_head_move)。
|
||||
maxBatchMoveSize = 2
|
||||
)
|
||||
|
||||
// ==================== 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 "批量移动失败:移动列表为空。"
|
||||
}
|
||||
if len(moves) > maxBatchMoveSize {
|
||||
return fmt.Sprintf("批量移动失败:当前最多支持 %d 条移动请求。请改用队列化逐项处理(queue_pop_head + queue_apply_head_move)。", maxBatchMoveSize)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user