后端: 1. newAgent 运行态重置双保险落地,并补齐写工具后的实时排程预览刷新 - 更新 model/common_state.go:新增 ResetForNextRun,统一清理 round/plan/rough_build/allow_reorder/terminal 等执行期临时状态 - 更新 node/chat.go + service/agentsvc/agent_newagent.go:在“无 pending 且上一轮已 done”时分别于 chat 主入口与 loadOrCreateRuntimeState 冷加载处执行兜底重置,覆盖正常新一轮对话与断线恢复场景 - 更新 model/graph_run_state.go + node/agent_nodes.go + node/execute.go:写工具执行后立即刷新 Redis 排程预览,Deliver 继续保留最终覆盖写,保证前端能及时看到最新操作结果 2. 顺序守卫从“直接中止”改为“优先自动复原 suggested 相对顺序” - 更新 node/order_guard.go:检测到 suggested 顺序被打乱后,不再直接 abort;改为复用当前坑位按 baseline 自动回填,并在复原失败时仅记录诊断日志后继续交付 - 更新 tools/state.go:ScheduleState 新增 RuntimeQueue 运行态快照字段,支持队列化处理与断线恢复 3. 多任务微调工具链升级:新增筛选/队列工具并替换首空位查询口径 - 新建 tools/read_filter_tools.go + tools/runtime_queue.go + tools/queue_tools.go:新增 query_available_slots / query_target_tasks / queue_pop_head / queue_apply_head_move / queue_skip_head / queue_status,支持“先筛选目标,再逐项处理”的稳定微调链路 - 更新 tools/registry.go + tools/write_tools.go + tools/read_helpers.go:移除 find_first_free 注册口径;batch_move 限制为最多 2 条,超过时引导改走队列逐项处理;queue_apply_head_move 纳入写工具集合 4. 复合规划工具扩充,并改为在 newAgent/tools 本地实现以规避循环导入 - 更新 tools/compound_tools.go + tools/registry.go:spread_even 正式接入,并与 min_context_switch 一起作为复合写工具保留在 newAgent/tools 内部实现,不再依赖外层 logic 5. prompt 与工具文档同步升级,明确当前用户诉求锚点与队列化执行约束 - 更新 prompt/execute.go + prompt/execute_context.go + prompt/plan.go:执行提示默认引导 query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head;补齐 batch_move 上限、spread_even 使用边界、顺序策略与工具 JSON 返回示例 - 更新 prompt/execute_context.go:将“初始用户目标”改为“当前用户诉求”,并保留首轮目标来源;旧 observation 折叠文案改为“当前工具调用结果已经被使用过,当前无需使用,为节省上下文空间,已折叠” - 更新 tools/SCHEDULE_TOOLS.md:同步补齐 query_* / queue_* / spread_even / min_context_switch 的说明、限制与返回示例 6. 同步更新调试日志文件 前端:无 仓库:无
968 lines
30 KiB
Go
968 lines
30 KiB
Go
package newagenttools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// refineSlotCandidate 是复合规划器使用的候选坑位输入。
|
||
type refineSlotCandidate struct {
|
||
Week int
|
||
DayOfWeek int
|
||
SectionFrom int
|
||
SectionTo int
|
||
}
|
||
|
||
// refineMovePlanItem 是规划器输出的一条移动方案。
|
||
type refineMovePlanItem struct {
|
||
TaskID int
|
||
ToWeek int
|
||
ToDay int
|
||
ToSectionFrom int
|
||
ToSectionTo int
|
||
}
|
||
|
||
// refinePlanOptions 是复合规划器的可选参数。
|
||
type refinePlanOptions struct {
|
||
ExistingDayLoad map[string]int
|
||
}
|
||
|
||
// MinContextSwitch 在给定任务集合内重排 suggested 任务,尽量减少上下文切换次数。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只处理“已落位的 suggested 任务”重排,不负责粗排;
|
||
// 2. 仅在给定 task_ids 集合内部重排,不改动集合外任务;
|
||
// 3. 采用原子提交:任一校验失败则整体不生效。
|
||
func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
|
||
if state == nil {
|
||
return "减少上下文切换失败:日程状态为空。"
|
||
}
|
||
|
||
// 1. 收集任务并做前置校验,确保规划输入可用。
|
||
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
|
||
if err != nil {
|
||
return err.Error()
|
||
}
|
||
|
||
// 2. 该工具固定在“当前任务已占坑位集合”内重排,不向外扩张候选位。
|
||
currentSlots := buildCurrentSlotsFromPlannerTasks(plannerTasks)
|
||
plannedMoves, err := planMinContextSwitchMoves(plannerTasks, currentSlots, refinePlanOptions{})
|
||
if err != nil {
|
||
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
|
||
}
|
||
|
||
// 3. 映射回工具态坐标并在提交前做完整校验。
|
||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves)
|
||
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 "均匀化调整失败:日程状态为空。"
|
||
}
|
||
|
||
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
|
||
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
|
||
if err != nil {
|
||
return err.Error()
|
||
}
|
||
|
||
// 2. 按跨度需求收集候选坑位,确保每类跨度都有可用池。
|
||
spanNeed := make(map[int]int, len(plannerTasks))
|
||
for _, task := range plannerTasks {
|
||
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 := planEvenSpreadMoves(plannerTasks, candidateSlots, refinePlanOptions{
|
||
ExistingDayLoad: dayLoadBaseline,
|
||
})
|
||
if err != nil {
|
||
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
|
||
}
|
||
|
||
// 4. 回填 + 校验 + 原子提交。
|
||
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves)
|
||
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, error) {
|
||
normalizedIDs := uniquePositiveInts(taskIDs)
|
||
if len(normalizedIDs) < 2 {
|
||
return nil, nil, nil, fmt.Errorf("%s失败:task_ids 至少需要 2 个有效任务 ID", toolLabel)
|
||
}
|
||
|
||
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, fmt.Errorf("%s失败:任务ID %d 不存在", toolLabel, taskID)
|
||
}
|
||
if !IsSuggestedTask(*task) {
|
||
return 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, fmt.Errorf("%s失败:%s", toolLabel, err.Error())
|
||
}
|
||
if len(task.Slots) != 1 {
|
||
return 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, 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, 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, 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, nil
|
||
}
|
||
|
||
func buildCurrentSlotsFromPlannerTasks(tasks []refineTaskCandidate) []refineSlotCandidate {
|
||
slots := make([]refineSlotCandidate, 0, len(tasks))
|
||
for _, task := range tasks {
|
||
slots = append(slots, refineSlotCandidate{
|
||
Week: task.Week,
|
||
DayOfWeek: task.DayOfWeek,
|
||
SectionFrom: task.SectionFrom,
|
||
SectionTo: task.SectionTo,
|
||
})
|
||
}
|
||
return slots
|
||
}
|
||
|
||
func buildAfterSnapshotsFromPlannedMoves(
|
||
state *ScheduleState,
|
||
beforeByID map[int]minContextSnapshot,
|
||
plannedMoves []refineMovePlanItem,
|
||
) (map[int]minContextSnapshot, error) {
|
||
if len(plannedMoves) == 0 {
|
||
return nil, fmt.Errorf("规划结果为空")
|
||
}
|
||
|
||
moveByID := make(map[int]refineMovePlanItem, len(plannedMoves))
|
||
for _, move := range plannedMoves {
|
||
if _, exists := moveByID[move.TaskID]; exists {
|
||
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", move.TaskID)
|
||
}
|
||
moveByID[move.TaskID] = 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,
|
||
) ([]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([]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, 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 []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 planEvenSpreadMoves(tasks []refineTaskCandidate, slots []refineSlotCandidate, options refinePlanOptions) ([]refineMovePlanItem, error) {
|
||
normalizedTasks, err := normalizePlannerTasks(tasks)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
normalizedSlots, err := normalizePlannerSlots(slots)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(normalizedSlots) < len(normalizedTasks) {
|
||
return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
|
||
}
|
||
|
||
dayLoad := make(map[string]int, len(options.ExistingDayLoad)+len(normalizedSlots))
|
||
for key, value := range options.ExistingDayLoad {
|
||
if value <= 0 {
|
||
continue
|
||
}
|
||
dayLoad[strings.TrimSpace(key)] = value
|
||
}
|
||
|
||
used := make([]bool, len(normalizedSlots))
|
||
moves := make([]refineMovePlanItem, 0, len(normalizedTasks))
|
||
selectedSlots := make([]refineSlotCandidate, 0, len(normalizedTasks))
|
||
|
||
for _, task := range normalizedTasks {
|
||
taskSpan := sectionSpan(task.SectionFrom, task.SectionTo)
|
||
bestIdx := -1
|
||
bestScore := int(^uint(0) >> 1)
|
||
|
||
for idx, slot := range normalizedSlots {
|
||
if used[idx] {
|
||
continue
|
||
}
|
||
if sectionSpan(slot.SectionFrom, slot.SectionTo) != taskSpan {
|
||
continue
|
||
}
|
||
if slotOverlapsAny(slot, selectedSlots) {
|
||
continue
|
||
}
|
||
dayKey := composeDayKey(slot.Week, slot.DayOfWeek)
|
||
projectedLoad := dayLoad[dayKey] + 1
|
||
score := projectedLoad*10000 + idx
|
||
if score < bestScore {
|
||
bestScore = score
|
||
bestIdx = idx
|
||
}
|
||
}
|
||
if bestIdx < 0 {
|
||
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskID)
|
||
}
|
||
|
||
chosen := normalizedSlots[bestIdx]
|
||
used[bestIdx] = true
|
||
selectedSlots = append(selectedSlots, chosen)
|
||
dayLoad[composeDayKey(chosen.Week, chosen.DayOfWeek)]++
|
||
moves = append(moves, refineMovePlanItem{
|
||
TaskID: task.TaskID,
|
||
ToWeek: chosen.Week,
|
||
ToDay: chosen.DayOfWeek,
|
||
ToSectionFrom: chosen.SectionFrom,
|
||
ToSectionTo: chosen.SectionTo,
|
||
})
|
||
}
|
||
return moves, nil
|
||
}
|
||
|
||
func planMinContextSwitchMoves(tasks []refineTaskCandidate, slots []refineSlotCandidate, _ refinePlanOptions) ([]refineMovePlanItem, error) {
|
||
normalizedTasks, err := normalizePlannerTasks(tasks)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
normalizedSlots, err := normalizePlannerSlots(slots)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(normalizedSlots) < len(normalizedTasks) {
|
||
return nil, fmt.Errorf("可用坑位不足:tasks=%d, slots=%d", len(normalizedTasks), len(normalizedSlots))
|
||
}
|
||
|
||
type taskGroup struct {
|
||
ContextKey string
|
||
Tasks []refineTaskCandidate
|
||
MinRank int
|
||
}
|
||
|
||
groupingKeys := buildMinContextGroupingKeys(normalizedTasks)
|
||
groupMap := make(map[string]*taskGroup, len(normalizedTasks))
|
||
groupOrder := make([]string, 0, len(normalizedTasks))
|
||
for _, task := range normalizedTasks {
|
||
key := groupingKeys[task.TaskID]
|
||
group, exists := groupMap[key]
|
||
if !exists {
|
||
group = &taskGroup{
|
||
ContextKey: key,
|
||
MinRank: normalizedOriginRank(task),
|
||
}
|
||
groupMap[key] = group
|
||
groupOrder = append(groupOrder, key)
|
||
}
|
||
group.Tasks = append(group.Tasks, task)
|
||
if rank := normalizedOriginRank(task); rank < group.MinRank {
|
||
group.MinRank = rank
|
||
}
|
||
}
|
||
|
||
groups := make([]taskGroup, 0, len(groupMap))
|
||
for _, key := range groupOrder {
|
||
group := groupMap[key]
|
||
sort.SliceStable(group.Tasks, func(i, j int) bool {
|
||
return compareTaskOrder(group.Tasks[i], group.Tasks[j]) < 0
|
||
})
|
||
groups = append(groups, *group)
|
||
}
|
||
sort.SliceStable(groups, func(i, j int) bool {
|
||
if len(groups[i].Tasks) != len(groups[j].Tasks) {
|
||
return len(groups[i].Tasks) > len(groups[j].Tasks)
|
||
}
|
||
if groups[i].MinRank != groups[j].MinRank {
|
||
return groups[i].MinRank < groups[j].MinRank
|
||
}
|
||
return groups[i].ContextKey < groups[j].ContextKey
|
||
})
|
||
|
||
orderedTasks := make([]refineTaskCandidate, 0, len(normalizedTasks))
|
||
for _, group := range groups {
|
||
orderedTasks = append(orderedTasks, group.Tasks...)
|
||
}
|
||
|
||
used := make([]bool, len(normalizedSlots))
|
||
selectedSlots := make([]refineSlotCandidate, 0, len(orderedTasks))
|
||
moves := make([]refineMovePlanItem, 0, len(orderedTasks))
|
||
for _, task := range orderedTasks {
|
||
span := sectionSpan(task.SectionFrom, task.SectionTo)
|
||
chosenIdx := -1
|
||
for idx, slot := range normalizedSlots {
|
||
if used[idx] {
|
||
continue
|
||
}
|
||
if sectionSpan(slot.SectionFrom, slot.SectionTo) != span {
|
||
continue
|
||
}
|
||
if slotOverlapsAny(slot, selectedSlots) {
|
||
continue
|
||
}
|
||
chosenIdx = idx
|
||
break
|
||
}
|
||
if chosenIdx < 0 {
|
||
return nil, fmt.Errorf("任务 id=%d 无可用同跨度坑位", task.TaskID)
|
||
}
|
||
chosen := normalizedSlots[chosenIdx]
|
||
used[chosenIdx] = true
|
||
selectedSlots = append(selectedSlots, chosen)
|
||
moves = append(moves, refineMovePlanItem{
|
||
TaskID: task.TaskID,
|
||
ToWeek: chosen.Week,
|
||
ToDay: chosen.DayOfWeek,
|
||
ToSectionFrom: chosen.SectionFrom,
|
||
ToSectionTo: chosen.SectionTo,
|
||
})
|
||
}
|
||
return moves, nil
|
||
}
|
||
|
||
func normalizePlannerTasks(tasks []refineTaskCandidate) ([]refineTaskCandidate, error) {
|
||
if len(tasks) == 0 {
|
||
return nil, fmt.Errorf("任务列表为空")
|
||
}
|
||
normalized := make([]refineTaskCandidate, 0, len(tasks))
|
||
seen := make(map[int]struct{}, len(tasks))
|
||
for _, task := range tasks {
|
||
if task.TaskID <= 0 {
|
||
return nil, fmt.Errorf("存在非法 task_id=%d", task.TaskID)
|
||
}
|
||
if _, exists := seen[task.TaskID]; exists {
|
||
return nil, fmt.Errorf("任务 id=%d 重复", task.TaskID)
|
||
}
|
||
if !isValidDay(task.DayOfWeek) {
|
||
return nil, fmt.Errorf("任务 id=%d day_of_week 非法=%d", task.TaskID, task.DayOfWeek)
|
||
}
|
||
if !isValidSection(task.SectionFrom, task.SectionTo) {
|
||
return nil, fmt.Errorf("任务 id=%d 节次区间非法=%d-%d", task.TaskID, task.SectionFrom, task.SectionTo)
|
||
}
|
||
seen[task.TaskID] = struct{}{}
|
||
normalized = append(normalized, task)
|
||
}
|
||
sort.SliceStable(normalized, func(i, j int) bool {
|
||
return compareTaskOrder(normalized[i], normalized[j]) < 0
|
||
})
|
||
return normalized, nil
|
||
}
|
||
|
||
func normalizePlannerSlots(slots []refineSlotCandidate) ([]refineSlotCandidate, error) {
|
||
if len(slots) == 0 {
|
||
return nil, fmt.Errorf("可用坑位为空")
|
||
}
|
||
normalized := make([]refineSlotCandidate, 0, len(slots))
|
||
seen := make(map[string]struct{}, len(slots))
|
||
for _, slot := range slots {
|
||
if slot.Week <= 0 {
|
||
return nil, fmt.Errorf("存在非法 week=%d", slot.Week)
|
||
}
|
||
if !isValidDay(slot.DayOfWeek) {
|
||
return nil, fmt.Errorf("存在非法 day_of_week=%d", slot.DayOfWeek)
|
||
}
|
||
if !isValidSection(slot.SectionFrom, slot.SectionTo) {
|
||
return nil, fmt.Errorf("存在非法节次区间=%d-%d", slot.SectionFrom, slot.SectionTo)
|
||
}
|
||
key := fmt.Sprintf("%d-%d-%d-%d", slot.Week, slot.DayOfWeek, slot.SectionFrom, slot.SectionTo)
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
normalized = append(normalized, slot)
|
||
}
|
||
sort.SliceStable(normalized, func(i, j int) bool {
|
||
if normalized[i].Week != normalized[j].Week {
|
||
return normalized[i].Week < normalized[j].Week
|
||
}
|
||
if normalized[i].DayOfWeek != normalized[j].DayOfWeek {
|
||
return normalized[i].DayOfWeek < normalized[j].DayOfWeek
|
||
}
|
||
if normalized[i].SectionFrom != normalized[j].SectionFrom {
|
||
return normalized[i].SectionFrom < normalized[j].SectionFrom
|
||
}
|
||
return normalized[i].SectionTo < normalized[j].SectionTo
|
||
})
|
||
return normalized, nil
|
||
}
|
||
|
||
func compareTaskOrder(a, b refineTaskCandidate) int {
|
||
rankA := normalizedOriginRank(a)
|
||
rankB := normalizedOriginRank(b)
|
||
if rankA != rankB {
|
||
return rankA - rankB
|
||
}
|
||
if a.Week != b.Week {
|
||
return a.Week - b.Week
|
||
}
|
||
if a.DayOfWeek != b.DayOfWeek {
|
||
return a.DayOfWeek - b.DayOfWeek
|
||
}
|
||
if a.SectionFrom != b.SectionFrom {
|
||
return a.SectionFrom - b.SectionFrom
|
||
}
|
||
if a.SectionTo != b.SectionTo {
|
||
return a.SectionTo - b.SectionTo
|
||
}
|
||
return a.TaskID - b.TaskID
|
||
}
|
||
|
||
func normalizedOriginRank(task refineTaskCandidate) int {
|
||
if task.OriginRank > 0 {
|
||
return task.OriginRank
|
||
}
|
||
return 1_000_000 + task.TaskID
|
||
}
|
||
|
||
func buildMinContextGroupingKeys(tasks []refineTaskCandidate) map[int]string {
|
||
keys := make(map[int]string, len(tasks))
|
||
distinctExplicit := make(map[string]struct{}, len(tasks))
|
||
distinctNonCoarse := make(map[string]struct{}, len(tasks))
|
||
for _, task := range tasks {
|
||
key := normalizeContextKey(task.ContextTag)
|
||
keys[task.TaskID] = key
|
||
distinctExplicit[key] = struct{}{}
|
||
if !isCoarseContextKey(key) {
|
||
distinctNonCoarse[key] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// 1. 显式标签已经足够区分时,直接沿用;
|
||
// 2. 仅在显式标签退化到粗粒度时,才尝试名称兜底。
|
||
if len(distinctNonCoarse) >= 2 {
|
||
return keys
|
||
}
|
||
if len(distinctExplicit) > 1 && len(distinctNonCoarse) > 0 {
|
||
return keys
|
||
}
|
||
|
||
inferredKeys := make(map[int]string, len(tasks))
|
||
distinctInferred := make(map[string]struct{}, len(tasks))
|
||
for _, task := range tasks {
|
||
inferred := inferSubjectContextKeyFromTaskName(task.Name)
|
||
if inferred == "" {
|
||
inferred = keys[task.TaskID]
|
||
}
|
||
inferredKeys[task.TaskID] = inferred
|
||
distinctInferred[inferred] = struct{}{}
|
||
}
|
||
if len(distinctInferred) >= 2 {
|
||
return inferredKeys
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func normalizeContextKey(tag string) string {
|
||
text := strings.TrimSpace(tag)
|
||
if text == "" {
|
||
return "General"
|
||
}
|
||
return text
|
||
}
|
||
|
||
func isCoarseContextKey(key string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||
case "", "general", "high-logic", "high_logic", "memory", "review":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func inferSubjectContextKeyFromTaskName(name string) string {
|
||
text := strings.ToLower(strings.TrimSpace(name))
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
// 1. 这里使用轻量关键词,不追求全学科覆盖;
|
||
// 2. 仅用于“显式标签不足”的兜底场景。
|
||
switch {
|
||
case strings.Contains(text, "概率"), strings.Contains(text, "随机变量"), strings.Contains(text, "贝叶斯"), strings.Contains(text, "分布"):
|
||
return "subject:probability"
|
||
case strings.Contains(text, "数制"), strings.Contains(text, "逻辑代数"), strings.Contains(text, "时序电路"), strings.Contains(text, "状态图"):
|
||
return "subject:digital_logic"
|
||
case strings.Contains(text, "离散"), strings.Contains(text, "图论"), strings.Contains(text, "集合"), strings.Contains(text, "命题逻辑"):
|
||
return "subject:discrete_math"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func slotOverlapsAny(candidate refineSlotCandidate, selected []refineSlotCandidate) bool {
|
||
for _, current := range selected {
|
||
if current.Week != candidate.Week || current.DayOfWeek != candidate.DayOfWeek {
|
||
continue
|
||
}
|
||
if current.SectionFrom <= candidate.SectionTo && candidate.SectionFrom <= current.SectionTo {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func composeDayKey(week, day int) string {
|
||
return fmt.Sprintf("%d-%d", week, day)
|
||
}
|
||
|
||
func sectionSpan(from, to int) int {
|
||
return to - from + 1
|
||
}
|
||
|
||
func isValidDay(day int) bool {
|
||
return day >= 1 && day <= 7
|
||
}
|
||
|
||
func isValidSection(from, to int) bool {
|
||
if from < 1 || to > 12 {
|
||
return false
|
||
}
|
||
return from <= to
|
||
}
|
||
|
||
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
|
||
}
|