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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user