后端: 1. chat 路由新增“二次粗排硬闸门”,避免粗排完成后的微调请求误触发再次 rough_build - 更新 node/chat.go:当上下文已存在 rough_build_done 且用户未明确要求“重新粗排/从头重排”时,强制关闭 needs_rough_build / needs_refine_after_rough_build;补充路由调试日志维度(needs_rough_build、allow_reorder、has_rough_build_done 等) - 更新 prompt/chat.go:补齐二次粗排强约束,明确“移动/微调/优化/均匀化/调顺序”默认走 refine,不再次触发 rough build 2. execute 历史分层与工具调用写回链路增强 - 更新 node/execute.go:next_plan 推进后写入 execute_step_advanced marker,供 prompt 按步骤边界归档 loop;新增统一 appendToolCallResultHistory,标准化 assistant tool_call + tool observation 配对写回 - 更新 node/execute.go:confirm accept 路径补齐 min_context_switch 顺序护栏,避免通过确认链路绕过“未授权打乱顺序”限制 - 更新 prompt/execute_context.go:ReAct 边界识别从 loop_closed 扩展到 loop_closed/step_advanced;执行态文案收敛为“existing 仅作事实参考不作为可移动目标”,并新增参数纪律提示 - 更新 service/agentsvc/agent_newagent.go:冷恢复重置时仅在 completed 场景补写 execute_loop_closed marker,保证下一轮上下文归档一致 3. 工具参数严格校验落地(禁止自造字段) - 新建 tools/arg_guard.go:新增 validateToolArgsStrict 白名单校验,未知字段直接报错(含 day_from/day_to -> day_start/day_end 提示) - 更新 tools/read_filter_tools.go:query_available_slots / query_target_tasks 接入参数白名单校验 - 更新 tools/compound_tools.go:spread_even 接入参数白名单校验 - 更新 prompt/execute.go:系统提示补齐“参数必须严格使用 schema 字段”强约束与非法别名示例 4. execute 范围护栏辅助能力预埋 - 更新 node/execute.go:新增步骤范围解析与日历参数解析辅助(周/天/周几提取、候选 day 估算、batch_move new_day 提取等),为后续步骤级范围拦截提供基础能力 5. 记忆模块方案文档升级(吸收 Mem0 机制) - 更新 memory/记忆模块实施计划.md:补充 Mem0 借鉴与取舍,新增 ADD/UPDATE/DELETE/NONE 决策状态机、UUID 映射防幻觉、JSON 容错链、threshold->reranker->fallback、三维隔离过滤与对应指标/测试项 6. 同步更新调试日志文件 - 更新 newAgent/Log.txt 前端:无 仓库:无
694 lines
22 KiB
Go
694 lines
22 KiB
Go
package newagenttools
|
||
|
||
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
|
||
}
|