Version: 0.9.11.dev.260409

后端:
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(面试优先版到产品可用版的落地路线)。
前端:无
仓库:无
This commit is contained in:
Losita
2026-04-09 22:20:30 +08:00
parent 821c2cde5d
commit 574d44c332
17 changed files with 9470 additions and 4539 deletions

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"sort"
"strings"
compositelogic "github.com/LoveLosita/smartflow/backend/logic"
)
// minContextSnapshot 记录任务在复合重排前后的最小快照,用于输出摘要。
@@ -27,26 +29,36 @@ type refineTaskCandidate struct {
OriginRank int
}
// refineSlotCandidate 是复合规划器使用的候选坑位输入
type refineSlotCandidate struct {
Week int
DayOfWeek int
SectionFrom int
SectionTo 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
}
// refineMovePlanItem 是规划器输出的一条移动方案
type refineMovePlanItem struct {
TaskID int
ToWeek int
ToDay int
ToSectionFrom int
ToSectionTo int
}
// refinePlanOptions 是复合规划器的可选参数。
type refinePlanOptions struct {
ExistingDayLoad map[string]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 任务,尽量减少上下文切换次数。
@@ -61,20 +73,24 @@ func MinContextSwitch(state *ScheduleState, taskIDs []int) string {
}
// 1. 收集任务并做前置校验,确保规划输入可用。
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "减少上下文切换")
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(plannerTasks)
plannedMoves, err := planMinContextSwitchMoves(plannerTasks, currentSlots, refinePlanOptions{})
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)
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
@@ -163,14 +179,18 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
}
// 1. 先做任务侧校验,避免后续规划在脏输入上执行。
plannerTasks, beforeByID, excludeIDs, err := collectCompositePlannerTasks(state, taskIDs, "均匀化调整")
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(plannerTasks))
for _, task := range plannerTasks {
spanNeed := make(map[int]int, len(logicTasks))
for _, task := range logicTasks {
spanNeed[task.SectionTo-task.SectionFrom+1]++
}
candidateSlots, err := collectSpreadEvenCandidateSlotsBySpan(state, args, spanNeed)
@@ -180,7 +200,7 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
// 3. 用“范围内既有负载”作为打分基线,让结果更接近均匀分布。
dayLoadBaseline := buildSpreadEvenDayLoadBaseline(state, excludeIDs, candidateSlots)
plannedMoves, err := planEvenSpreadMoves(plannerTasks, candidateSlots, refinePlanOptions{
plannedMoves, err := compositelogic.PlanEvenSpreadMoves(logicTasks, candidateSlots, compositelogic.RefineCompositePlanOptions{
ExistingDayLoad: dayLoadBaseline,
})
if err != nil {
@@ -188,7 +208,7 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
}
// 4. 回填 + 校验 + 原子提交。
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves)
afterByID, err := buildAfterSnapshotsFromPlannedMoves(state, beforeByID, plannedMoves, idMapper)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
@@ -283,10 +303,15 @@ func collectCompositePlannerTasks(
state *ScheduleState,
taskIDs []int,
toolLabel string,
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, error) {
) ([]refineTaskCandidate, map[int]minContextSnapshot, []int, *compositeIDMapper, error) {
normalizedIDs := uniquePositiveInts(taskIDs)
if len(normalizedIDs) < 2 {
return nil, nil, nil, fmt.Errorf("%s失败task_ids 至少需要 2 个有效任务 ID", toolLabel)
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))
@@ -296,28 +321,28 @@ func collectCompositePlannerTasks(
for rank, taskID := range normalizedIDs {
task := state.TaskByStateID(taskID)
if task == nil {
return nil, nil, nil, fmt.Errorf("%s失败任务ID %d 不存在", toolLabel, taskID)
return nil, 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)
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, fmt.Errorf("%s失败%s", toolLabel, err.Error())
return nil, 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))
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, fmt.Errorf("%s失败[%d]%s 的时段非法:%s", toolLabel, task.StateID, task.Name, err.Error())
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, fmt.Errorf("%s失败[%d]%s 的节次非法:%s", toolLabel, task.StateID, task.Name, err.Error())
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, fmt.Errorf("%s失败[%d]%s 的 day=%d 无法映射到 week/day_of_week", toolLabel, task.StateID, task.Name, slot.Day)
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)
@@ -340,13 +365,41 @@ func collectCompositePlannerTasks(
})
}
return plannerTasks, beforeByID, excludeIDs, nil
return plannerTasks, beforeByID, excludeIDs, idMapper, nil
}
func buildCurrentSlotsFromPlannerTasks(tasks []refineTaskCandidate) []refineSlotCandidate {
slots := make([]refineSlotCandidate, 0, len(tasks))
// 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 {
slots = append(slots, refineSlotCandidate{
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,
@@ -359,18 +412,26 @@ func buildCurrentSlotsFromPlannerTasks(tasks []refineTaskCandidate) []refineSlot
func buildAfterSnapshotsFromPlannedMoves(
state *ScheduleState,
beforeByID map[int]minContextSnapshot,
plannedMoves []refineMovePlanItem,
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]refineMovePlanItem, len(plannedMoves))
moveByID := make(map[int]compositelogic.RefineMovePlanItem, len(plannedMoves))
for _, move := range plannedMoves {
if _, exists := moveByID[move.TaskID]; exists {
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", move.TaskID)
stateID, ok := idMapper.logicToState[move.TaskItemID]
if !ok {
return nil, fmt.Errorf("规划结果包含未知 logic 任务 id=%d", move.TaskItemID)
}
moveByID[move.TaskID] = move
if _, exists := moveByID[stateID]; exists {
return nil, fmt.Errorf("规划结果包含重复任务 id=%d", stateID)
}
moveByID[stateID] = move
}
afterByID := make(map[int]minContextSnapshot, len(beforeByID))
@@ -401,7 +462,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
state *ScheduleState,
args map[string]any,
spanNeed map[int]int,
) ([]refineSlotCandidate, error) {
) ([]compositelogic.RefineSlotCandidate, error) {
if len(spanNeed) == 0 {
return nil, fmt.Errorf("未识别到任务跨度需求")
}
@@ -412,7 +473,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
}
sort.Ints(spans)
allSlots := make([]refineSlotCandidate, 0, 16)
allSlots := make([]compositelogic.RefineSlotCandidate, 0, 16)
seen := make(map[string]struct{}, 64)
for _, span := range spans {
required := spanNeed[span]
@@ -441,7 +502,7 @@ func collectSpreadEvenCandidateSlotsBySpan(
continue
}
seen[key] = struct{}{}
allSlots = append(allSlots, refineSlotCandidate{
allSlots = append(allSlots, compositelogic.RefineSlotCandidate{
Week: slot.Week,
DayOfWeek: slot.DayOfWeek,
SectionFrom: slot.SlotStart,
@@ -494,7 +555,7 @@ func buildSpreadEvenSlotQueryArgs(args map[string]any, span int, required int) m
func buildSpreadEvenDayLoadBaseline(
state *ScheduleState,
excludeTaskIDs []int,
slots []refineSlotCandidate,
slots []compositelogic.RefineSlotCandidate,
) map[string]int {
if len(slots) == 0 {
return nil
@@ -536,370 +597,10 @@ func buildSpreadEvenDayLoadBaseline(
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))