后端: 1. conv 并行迁移与切流接线(旧目录下沉到 newAgent/conv) - 新建 newAgent/conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go,保持原有排程转换/预览/持久化能力; - 删除旧目录 conv/schedule_provider.go、schedule_state.go、schedule_preview.go、schedule_persist.go; - 更新 cmd/start.go 与 service/agentsvc/agent_newagent.go,ScheduleProvider/SchedulePersistor 与 preview 转换统一切到 newAgent/conv; - 删除旧 conv/schedule_state_test.go(迁移期测试文件清理)。 2. execute 循环上下文收口增强(历史归档 + 当前轮清晰化) - 更新 node/chat.go:仅在 completed 收口时写 execute_loop_closed marker,供后续 prompt 分层归档; - 更新 prompt/execute_context.go:msg1/msg2 升级为 V3,按收口标记拆分“历史归档 loop / 当前活跃 loop”,并增加 msg1 长度预算裁剪; - 更新 node/execute.go:新增 execute 置顶上下文同步(execution_context/current_step),在轮次开始与 next_plan 后即时刷新; - 更新 prompt/execute.go + execute_context.go:补齐“当前计划步骤 + done_when”强约束,禁止未达成判定时提前 next_plan。 3. 图路由与执行策略微调 - 更新 graph/common_graph.go:Plan/Confirm 分支允许直接进入 Deliver 收口; - 更新 node/plan.go:always_execute 链路下补发计划摘要并写入历史,保证自动执行与手动确认文案一致; - 更新 model/common_state.go:DefaultMaxRounds 从 30 提升到 60。 4. 复合工具规划器重构(去重实现,复用 logic 公共能力) - 更新 tools/compound_tools.go:min_context_switch / spread_even 改为调用 backend/logic 规划器(PlanMinContextSwitchMoves / PlanEvenSpreadMoves); - 新增 state_id↔logic_id 映射层,统一入参与回填,避免工具层与规划层 ID 语义耦合; - 删除 compound_tools 内部重复的规划/归一化/分组/打分实现,减少第三份复制逻辑。 5. 同步调试与文档 - 更新 newAgent/Log.txt 调试日志; - 新增 memory/记忆模块实施计划.md(面试优先版到产品可用版的落地路线)。 前端:无 仓库:无
669 lines
22 KiB
Go
669 lines
22 KiB
Go
package newagenttools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
|
||
)
|
||
|
||
// 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 "均匀化调整失败:日程状态为空。"
|
||
}
|
||
|
||
// 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
|
||
}
|