Files
smartmate/backend/newAgent/tools/compound_tools.go
Losita ee34d5f111 Version: 0.9.12.dev.260410
后端:
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
前端:无
仓库:无
2026-04-10 00:38:02 +08:00

694 lines
22 KiB
Go
Raw Blame History

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