Files
smartmate/backend/logic/smart_planning.go
Losita 66c06eed0a Version: 0.9.45.dev.260427
后端:
1. execute 主链路重构为“上下文工具域 + 主动优化候选闭环”——移除 order_guard,粗排后默认进入主动微调,先诊断再从后端候选中选择 move/swap,避免 LLM 自由全局乱搜
2. 工具体系升级为动态注入协议——新增 context_tools_add / remove、工具域与二级包映射、主动优化白名单;schedule / taskclass / web 工具按域按包暴露,msg0 规则包与 execute 上下文同步重写
3. analyze_health 升级为主动优化唯一裁判入口——补齐 rhythm / tightness / profile / feasibility 指标、候选扫描与复诊打分、停滞信号、forced imperfection 判定,并把连续优化状态写回运行态
4. 任务类能力并入新 Agent 执行链——新增 upsert_task_class 写工具与启动注入事务写入;任务类模型补充学科画像与整天屏蔽配置,粗排支持 excluded_days_of_week,steady 策略改为基于目标位置/单日负载/分散度/缓冲的候选打分
5. 运行态与路由补齐优化模式语义——新增 active tool domain/packs、pending context hook、active optimize only、taskclass 写入回盘快照;区分 first_full / global_reopt / local_adjust,并完善首次粗排后默认 refine 的判定

前端:
6. 助手时间线渲染细化——推理内容改为独立 reasoning block,支持与工具/状态/正文按时序交错展示,自动收口折叠,修正 confirm reject 恢复动作

仓库:
7. newAgent 文档整体迁入 docs/backend,补充主动优化执行规划与顺序约束拆解文档,删除旧调试日志文件

PS:这次科研了2天,总算是有些进展了——LLM永远只适合做选择题、判断题,不适合做开放创新题。
2026-04-27 01:09:37 +08:00

824 lines
26 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 logic
import (
"fmt"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
)
type slotStatus int
const (
Free slotStatus = iota // 0: 纯空闲
Occupied // 1: 已有课/任务,不可动
Blocked // 2: 用户屏蔽时段
Filler // 3: 水课,允许嵌入
)
type slotNode struct {
Status slotStatus
EventID uint // 🚀 关键:记录课程 ID用于识别水课边界
}
type grid struct {
data map[int]map[int][13]slotNode
startWeek int
startDay int
endWeek int
endDay int
}
// getNode 和 setNode 是对 grid 数据结构的封装确保我们在访问时能正确处理默认值Free和边界情况
func (g *grid) getNode(w, d, s int) slotNode {
if dayMap, ok := g.data[w]; ok {
return dayMap[d][s]
}
return slotNode{Status: Free, EventID: 0}
}
func (g *grid) setNode(w, d, s int, node slotNode) {
if _, ok := g.data[w]; !ok {
g.data[w] = make(map[int][13]slotNode)
}
dayData := g.data[w][d]
dayData[s] = node
g.data[w][d] = dayData
}
// 检查是否可用 (Free 或 Filler 且不在 Blocked 时段内)
func (g *grid) isAvailable(w, d, s int) bool {
node := g.getNode(w, d, s)
return node.Status == Free || node.Status == Filler
}
// countAvailableSlots 统计指定周次范围内所有可用的原子节次总数
func (g *grid) countAvailableSlots(currW, currD, currS int) int {
count := 0
if currW == 0 && currD == 0 && currS == 0 {
currW, currD, currS = g.startWeek, g.startDay, 1
}
for w := currW; w <= g.endWeek; w++ {
dayMap, hasData := g.data[w]
for d := 1; d <= 7; d++ {
// 🚀 头部裁剪:过滤开始日期前的天数
if w == currW && d < currD {
continue
}
// 🚀 尾部裁剪:过滤结束日期后的天数
if w == g.endWeek && d > g.endDay {
break
}
var dayData [13]slotNode
if hasData {
dayData = dayMap[d]
}
for s := 1; s <= 12; s++ {
if w == currW && d == currD && s < currS {
continue
}
if dayData[s].Status == Free || dayData[s].Status == Filler {
count++
}
}
}
}
return count
}
// FindNextAvailable 从当前时间点开始,按周、天、节次顺序查找下一个可用格子
func (g *grid) FindNextAvailable(currW, currD, currS int) (int, int, int) {
// 基础越界检查
if currW > g.endWeek || (currW == g.endWeek && currD > g.endDay) {
return -1, -1, -1
}
for w := currW; w <= g.endWeek; w++ {
dayMap, hasData := g.data[w]
for d := 1; d <= 7; d++ {
if w == currW && d < currD {
continue
}
if w == g.endWeek && d > g.endDay {
break
} // 🚀 守住结束天
var dayData [13]slotNode
if hasData {
dayData = dayMap[d]
}
for s := 1; s <= 12; s++ {
if w == currW && d == currD && s < currS {
continue
}
if dayData[s].Status == Free || dayData[s].Status == Filler {
return w, d, s
}
}
}
}
return -1, -1, -1
}
// 辅助函数:向后跳过指定数量的可用坑位
func (g *grid) skipAvailableSlots(w, d, s, skipCount int) (int, int, int) {
if skipCount <= 0 {
// 即使 gap 为 0也要至少移到下一节
s++
if s > 12 {
s = 1
d++
if d > 7 {
d = 1
w++
}
}
return w, d, s
}
found := 0
currW, currD, currS := w, d, s+1
for currW <= g.endWeek {
if currS > 12 {
currS = 1
currD++
if currD > 7 {
currD = 1
currW++
}
continue
}
// 如果已经跳到了最后一天,不要再跳了,直接返回终点坐标
if currW == g.endWeek && currD > g.endDay {
return g.endWeek, g.endDay, 12
}
if g.isAvailable(currW, currD, currS) {
found++
if found > skipCount {
return currW, currD, currS
}
}
currS++
}
return currW, currD, currS
}
func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskClass) ([]model.UserWeekSchedule, error) {
//1.先构建时间格子
g := buildTimeGrid(schedules, taskClass)
//2.根据时间格子和排课策略计算每个任务块的具体安排时间
allocatedItems, err := computeAllocation(g, taskClass.Items, *taskClass.Strategy)
if err != nil {
return nil, err
}
//3.把这些时间通过DTO函数回填到涉<E588B0><E6B689>周的 UserWeekSchedule 结构中,供前端展示
return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil
}
// SmartPlanningRawItems 执行粗排算法并直接返回已分配的任务项列表。
//
// 与 SmartPlanningMainLogic 共享完全相同的构建网格和分配逻辑,
// 但不做展示格式转换,直接返回 allocatedItems每项的 EmbeddedTime 已回填)。
// 供 Agent 排程链路使用,避免从展示结构反向解析导致信息丢失。
func SmartPlanningRawItems(schedules []model.Schedule, taskClass *model.TaskClass) ([]model.TaskClassItem, error) {
g := buildTimeGrid(schedules, taskClass)
return computeAllocation(g, taskClass.Items, *taskClass.Strategy)
}
// SmartPlanningRawItemsMulti 执行“多任务类共享资源池”粗排。
//
// 职责边界:
// 1. 复用现有 SmartPlanningRawItems 的单任务类分配能力,不重写核心算法;
// 2. 通过“增量占位”把前一个任务类的建议结果写入共享工作日程,供后续任务类避让;
// 3. 返回聚合后的 allocatedItems每项 EmbeddedTime 已回填);
// 4. 不负责展示结构转换(由 service/conv 层处理)。
func SmartPlanningRawItemsMulti(schedules []model.Schedule, taskClasses []*model.TaskClass) ([]model.TaskClassItem, error) {
if len(taskClasses) == 0 {
return []model.TaskClassItem{}, nil
}
// 1. 构建“工作副本”:
// 1.1 原始 schedules 不直接修改,避免污染调用方数据;
// 1.2 后续每完成一个任务类分配,就把结果增量写入 workingSchedules。
workingSchedules := cloneSchedulesForPlanning(schedules)
allAllocated := make([]model.TaskClassItem, 0)
// 2. syntheticEventID 用于给“虚拟占位任务”分配唯一 EventID。
// 2.1 采用负数区间,避免和数据库自增正数 EventID 冲突;
// 2.2 每个任务块占用一个 synthetic event跨节次共享同一 eventID。
nextSyntheticEventID := -1
for _, taskClass := range taskClasses {
if taskClass == nil {
continue
}
if taskClass.Strategy == nil {
return nil, fmt.Errorf("task_class_id=%d 缺少 strategy 配置", taskClass.ID)
}
// 3. 复用单任务类粗排。
allocatedItems, err := SmartPlanningRawItems(workingSchedules, taskClass)
if err != nil {
// 3.1 明确标注失败任务类,便于上层快速定位。
return nil, fmt.Errorf("task_class_id=%d 粗排失败: %w", taskClass.ID, err)
}
allAllocated = append(allAllocated, allocatedItems...)
// 4. 把本任务类分配结果转成“虚拟 Schedule 占位”追加回工作副本。
// 4.1 目的:让后续任务类把这些已分配任务当成 Occupied避免重叠
// 4.2 若某任务块没有 EmbeddedTime直接跳过不阻断后续。
virtualSchedules, nextID := buildVirtualSchedulesFromAllocated(allocatedItems, taskClass, nextSyntheticEventID)
nextSyntheticEventID = nextID
if len(virtualSchedules) > 0 {
workingSchedules = append(workingSchedules, virtualSchedules...)
}
}
return allAllocated, nil
}
// cloneSchedulesForPlanning 深拷贝 schedules确保后续在算法中安全修改。
//
// 说明:
// 1. 主要拷贝 Schedule 结构体本身;
// 2. Event 指针做浅字段复制,避免共享同一 Event 指针导致意外改写;
// 3. EmbeddedTask 在粗排阶段不参与状态写入,保留原值即可。
func cloneSchedulesForPlanning(src []model.Schedule) []model.Schedule {
if len(src) == 0 {
return []model.Schedule{}
}
dst := make([]model.Schedule, len(src))
for i := range src {
dst[i] = src[i]
if src[i].Event != nil {
eventCopy := *src[i].Event
dst[i].Event = &eventCopy
}
}
return dst
}
// buildVirtualSchedulesFromAllocated 将已分配任务块转成“虚拟占位 schedules”。
//
// 设计目的:
// 1. 让后续任务类在共享资源池里自动避让已分配任务;
// 2. 不落库,仅用于内存中的粗排冲突控制;
// 3. 通过 Type=task + CanBeEmbedded=false 强制标记为不可再嵌入。
func buildVirtualSchedulesFromAllocated(allocatedItems []model.TaskClassItem, taskClass *model.TaskClass, eventIDStart int) ([]model.Schedule, int) {
if len(allocatedItems) == 0 {
return []model.Schedule{}, eventIDStart
}
userID := 0
if taskClass != nil && taskClass.UserID != nil {
userID = *taskClass.UserID
}
virtual := make([]model.Schedule, 0)
nextEventID := eventIDStart
for _, item := range allocatedItems {
if item.EmbeddedTime == nil {
continue
}
taskName := "未命名任务"
if item.Content != nil && *item.Content != "" {
taskName = *item.Content
}
location := ""
event := &model.ScheduleEvent{
ID: nextEventID,
UserID: userID,
Name: taskName,
Location: &location,
Type: "task",
CanBeEmbedded: false,
}
for section := item.EmbeddedTime.SectionFrom; section <= item.EmbeddedTime.SectionTo; section++ {
virtual = append(virtual, model.Schedule{
EventID: nextEventID,
UserID: userID,
Week: item.EmbeddedTime.Week,
DayOfWeek: item.EmbeddedTime.DayOfWeek,
Section: section,
Event: event,
Status: "normal",
})
}
nextEventID--
}
return virtual, nextEventID
}
// buildTimeGrid 构建一个时间格子,标记出哪些时间段被占用、哪些被屏蔽、哪些是水课
func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid {
// 🚀 核心修正:获取精确的起始坐标
startW, startD, _ := conv.RealDateToRelativeDate(taskClass.StartDate.Format(conv.DateFormat))
endW, endD, _ := conv.RealDateToRelativeDate(taskClass.EndDate.Format(conv.DateFormat))
// 将信息初始化到 grid 结构中
g := &grid{
data: make(map[int]map[int][13]slotNode),
startWeek: startW,
startDay: startD,
endWeek: endW,
endDay: endD,
}
//标记屏蔽时段 (Blocked)
for _, blockIdx := range taskClass.ExcludedSlots {
sFrom, sTo := (blockIdx-1)*2+1, blockIdx*2
for w := startW; w <= endW; w++ {
for d := 1; d <= 7; d++ { //🚀 注意:这里的屏蔽是针对每天的,所以直接循环 1-7 天
for s := sFrom; s <= sTo; s++ {
g.setNode(w, d, s, slotNode{Status: Blocked})
}
}
}
}
// 标记整天屏蔽:
// 1. excluded_days_of_week 表示“这些星期几整天都不允许粗排”;
// 2. 与 excluded_slots 一样属于硬约束,因此直接写入 Blocked
// 3. 一旦工作日容量不足,粗排应直接失败,而不是偷偷排到被排除的星期里。
for _, blockedDay := range taskClass.ExcludedDaysOfWeek {
for w := startW; w <= endW; w++ {
for s := 1; s <= 12; s++ {
g.setNode(w, blockedDay, s, slotNode{Status: Blocked})
}
}
}
// 映射日程 (尊重 Blocked 且只处理范围内的数据)
for _, s := range schedules {
if s.Week >= startW && s.Week <= endW {
if g.getNode(s.Week, s.DayOfWeek, s.Section).Status == Blocked {
continue
}
status := Occupied
// 只有当课程允许嵌入且当前事件支持嵌入时,才标记为 Filler
if *taskClass.AllowFillerCourse && s.Event.CanBeEmbedded {
status = Filler
}
g.setNode(s.Week, s.DayOfWeek, s.Section, slotNode{Status: status, EventID: uint(s.EventID)})
}
}
return g
}
// computeAllocation 是核心函数,负责根据当前的时间格子状态和排课策略,计算出每个任务块的具体安排时间
/*func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([]model.TaskClassItem, error) {
if len(items) == 0 {
return items, nil
}
// 🚀 核心修正 1获取真正的开始坐标周、天、节
// 这里假设你已经通过 conv 把 StartDate 换成了 w1, d1, s1
startW := g.startWeek
startD := g.startDay // 建议从 conv 传入具体的 DayOfWeek
startS := 1
// 1. 获取可用资源总量
totalAvailable := g.countAvailableSlots(0, 0, 0)
// 假设每个任务块至少占用 2 个原子槽位
totalRequired := len(items) * 2
// 🚀 核心改进:容量预判
if totalAvailable < totalRequired {
// 如果连最基本的坑位都不够,直接报错,不进行任何编排
return nil, respond.TimeNotEnoughForAutoScheduling
}
// 🚀 核心修正 2步长改为“逻辑间隔”不再是物理跳跃
// gap 表示:每两个任务之间,我们要故意空出多少个“可用位”
gap := 0
if strategy == "steady" && totalAvailable > totalRequired {
gap = (totalAvailable - totalRequired) / (len(items) + 1)
}
currW, currD, currS := startW, startD, startS
lastPlacedIndex := -1
for i := range items {
w, d, s := g.FindNextAvailable(currW, currD, currS)
if w == -1 || w > g.endWeek {
break
}
node := g.getNode(w, d, s)
slotLen := 2
if node.Status == Filler {
slotLen = 1
currID := node.EventID
for checkS := s + 1; checkS <= 12; checkS++ {
if next := g.getNode(w, d, checkS); next.Status == Filler && next.EventID == currID {
slotLen++
} else {
break
}
}
}
endS := s + slotLen - 1
items[i].EmbeddedTime = &model.TargetTime{
SectionFrom: s, SectionTo: endS,
Week: w, DayOfWeek: d,
}
for sec := s; sec <= endS; sec++ {
g.setNode(w, d, sec, slotNode{Status: Occupied})
}
// 🚀 核心修正 3基于“可用位”推进指针而非物理索引
// 我们要在 grid 中向后数出 gap 个可用位置,作为下一个任务的起点
currW, currD, currS = g.skipAvailableSlots(w, d, endS, gap)
lastPlacedIndex = i // 记录最后一个成功安放的任务索引
}
// 🚀 核心改进:结果完整性校验
if lastPlacedIndex < len(items)-1 {
return nil, fmt.Errorf("排程中断:由于时间片碎片化,仅成功安排了 %d/%d 个任务块,请尝试扩充时间范围或删减屏蔽位", lastPlacedIndex+1, len(items))
return nil, respond.TimeNotEnoughForAutoScheduling
}
return items, nil
}*/
type slotCoord struct {
w, d, s int
}
// planningSlotCandidate 表示一次“可落位任务块”的候选结果。
//
// 职责边界:
// 1. 负责把“游标位置”映射成真正可落地的周/天/节次区间;
// 2. 不负责写入 grid占位仍由 computeAllocation 统一执行;
// 3. 通过 coordIndex 告诉上层“本次是从哪个逻辑切片位置开始命中的”,便于继续推进游标。
type planningSlotCandidate struct {
coordIndex int
week int
dayOfWeek int
sectionFrom int
sectionTo int
}
// countDayAvailable 统计某一天当前还可用于粗排的节次数。
//
// 职责边界:
// 1. 只把 Free/Filler 视为“仍可消费”的资源;
// 2. 不区分其来源是纯空位还是可嵌入课程,因为对粗排而言二者都代表后续还能放任务;
// 3. 仅用于候选打分,不直接参与最终合法性判断。
func (g *grid) countDayAvailable(week, day int) int {
if g == nil {
return 0
}
count := 0
for section := 1; section <= 12; section++ {
node := g.getNode(week, day, section)
if node.Status == Free || node.Status == Filler {
count++
}
}
return count
}
// countDayOccupied 统计某一天当前已被 existing/virtual/task 占住的节次数。
func (g *grid) countDayOccupied(week, day int) int {
if g == nil {
return 0
}
count := 0
for section := 1; section <= 12; section++ {
if g.getNode(week, day, section).Status == Occupied {
count++
}
}
return count
}
// collectPlanningCandidatesFromCursor 收集从给定游标开始仍然合法的候选落位。
//
// 设计说明:
// 1. 这里复用现有 findNextCandidateFromCursor 的合法性规则,避免复制一套“什么叫合法双节”的判断;
// 2. 通过跳过已命中候选的跨度,减少同一课程块被重复返回;
// 3. 保留快照上的 coordIndex供 steady 策略计算“距离目标位置有多远”。
func (g *grid) collectPlanningCandidatesFromCursor(coords []slotCoord, startCursor int) []planningSlotCandidate {
if g == nil || startCursor >= len(coords) {
return nil
}
candidates := make([]planningSlotCandidate, 0, 16)
seen := make(map[string]struct{})
for cursor := startCursor; cursor < len(coords); {
candidate, found := g.findNextCandidateFromCursor(coords, cursor)
if !found {
break
}
key := fmt.Sprintf("%d-%d-%d-%d", candidate.week, candidate.dayOfWeek, candidate.sectionFrom, candidate.sectionTo)
if _, exists := seen[key]; !exists {
seen[key] = struct{}{}
candidates = append(candidates, candidate)
}
nextCursor := candidate.coordIndex + (candidate.sectionTo - candidate.sectionFrom + 1)
if nextCursor <= cursor {
nextCursor = cursor + 1
}
cursor = nextCursor
}
return candidates
}
func computeSteadyTargetCursor(totalAvailable, totalItems, itemIndex int) int {
if totalAvailable <= 1 || totalItems <= 1 {
return 0
}
target := ((itemIndex + 1) * totalAvailable) / (totalItems + 1)
if target < 0 {
return 0
}
if target >= totalAvailable {
return totalAvailable - 1
}
return target
}
func planningDayOrdinal(week, day int) int {
return week*7 + day
}
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
// chooseSteadyCandidate 为 steady 策略挑选“更均衡、更分散、更留余地”的候选位。
//
// 评分原则:
// 1. 先尽量接近本任务在窗口中的目标分布位置;
// 2. 再偏好当前已占用更少的天,避免单日继续堆高;
// 3. 再惩罚与同任务类既有落位过近或同日重复,降低同科过度集中;
// 4. 最后惩罚吃掉当天最后一小段缓冲,给后续调整保留容错空间。
func (g *grid) chooseSteadyCandidate(
coords []slotCoord,
targetCursor int,
placedDayOrdinals []int,
) (planningSlotCandidate, bool) {
candidates := g.collectPlanningCandidatesFromCursor(coords, 0)
if len(candidates) == 0 {
return planningSlotCandidate{}, false
}
best := candidates[0]
bestScore := int(^uint(0) >> 1)
for _, candidate := range candidates {
slotSpan := candidate.sectionTo - candidate.sectionFrom + 1
distancePenalty := absInt(candidate.coordIndex-targetCursor) * 10
dayOccupiedPenalty := g.countDayOccupied(candidate.week, candidate.dayOfWeek) * 25
remainingAvailable := g.countDayAvailable(candidate.week, candidate.dayOfWeek) - slotSpan
bufferPenalty := 0
if remainingAvailable < 2 {
bufferPenalty = 80
}
dayOrdinal := planningDayOrdinal(candidate.week, candidate.dayOfWeek)
rhythmPenalty := 0
for _, placed := range placedDayOrdinals {
diff := absInt(dayOrdinal - placed)
switch {
case diff == 0:
rhythmPenalty += 180
case diff == 1:
rhythmPenalty += 60
}
}
score := distancePenalty + dayOccupiedPenalty + bufferPenalty + rhythmPenalty + candidate.coordIndex
if score < bestScore {
bestScore = score
best = candidate
}
}
return best, true
}
// getAllAvailable 获取窗口内所有可用的原子节次坐标(逻辑一维化)。
//
// 设计说明:
// 1. 这里返回的是“快照坐标”,后续任务落位后,快照中的部分坐标可能失效;
// 2. 因此 computeAllocation 在真正落位前会再次检查 grid 当前状态,避免覆盖占位。
func (g *grid) getAllAvailable() []slotCoord {
var coords []slotCoord
for w := g.startWeek; w <= g.endWeek; w++ {
dayMap, hasData := g.data[w]
for d := 1; d <= 7; d++ {
// 1. 头尾边界裁剪:只遍历任务类有效日期窗口。
if w == g.startWeek && d < g.startDay {
continue
}
if w == g.endWeek && d > g.endDay {
break
}
var dayData [13]slotNode
if hasData {
dayData = dayMap[d]
}
// 2. 仅记录可用格子Free/Filler
for s := 1; s <= 12; s++ {
if dayData[s].Status == Free || dayData[s].Status == Filler {
coords = append(coords, slotCoord{w: w, d: d, s: s})
}
}
}
}
return coords
}
// findNextCandidateFromCursor 从当前 cursor 起向后寻找“可真正落位”的候选块。
//
// 职责边界:
// 1. 负责“挑选起点”:从逻辑切片 coords 中向后扫描,直到命中可放置位置;
// 2. 不负责“真正占位”:这里只做判断,不修改 grid 状态;
// 3. 输入输出语义:
// - startCursor当前逻辑游标已包含 steady 策略的间隔效果);
// - found=false表示从该游标到窗口末尾都无法再放置任务块。
//
// 关键约束:
// 1. 普通空位Free必须满足“连续 2 节都可用”才允许落位;
// 2. 可嵌入课程Filler沿用“整块嵌入”语义命中课程任意节次都回溯到课程块起点并整块占用
// 3. 若某个坐标在前序迭代中已占用coords 为快照可能过期),直接跳过继续扫描。
func (g *grid) findNextCandidateFromCursor(coords []slotCoord, startCursor int) (candidate planningSlotCandidate, found bool) {
for idx := startCursor; idx < len(coords); idx++ {
loc := coords[idx]
node := g.getNode(loc.w, loc.d, loc.s)
// 1. 快照过期校验:
// 1.1 前序任务落位后,该坐标可能已变成 Occupied
// 1.2 若不二次校验,会出现覆盖已占位节次的风险。
if node.Status != Free && node.Status != Filler {
continue
}
// 2. Filler 处理:
// 2.1 先识别课程块边界;
// 2.2 再在课程块内部寻找“奇数起点的双节对齐位”1-2/3-4/...
// 2.3 找不到合法双节位则跳过该课程块,不允许退化成单节或偶数起点跨对齐块。
if node.Status == Filler {
blockFrom := loc.s
currID := node.EventID
// 2.1 向左回溯到同一 EventID 的起点。
for checkS := loc.s - 1; checkS >= 1; checkS-- {
prev := g.getNode(loc.w, loc.d, checkS)
if prev.Status == Filler && prev.EventID == currID {
blockFrom = checkS
continue
}
break
}
// 2.2 向右扩展到同一 EventID 的终点。
blockTo := blockFrom
for checkS := blockFrom + 1; checkS <= 12; checkS++ {
next := g.getNode(loc.w, loc.d, checkS)
if next.Status == Filler && next.EventID == currID {
blockTo = checkS
continue
}
break
}
// 2.3 在课程块中按“双节对齐位”查找合法起点(必须为奇数节)。
pairFrom := blockFrom
if pairFrom%2 == 0 {
pairFrom++
}
for ; pairFrom+1 <= blockTo; pairFrom += 2 {
// 虽然理论上 Filler 都可用,这里仍做显式校验,防止后续规则扩展导致误判。
if g.isAvailable(loc.w, loc.d, pairFrom) && g.isAvailable(loc.w, loc.d, pairFrom+1) {
return planningSlotCandidate{
coordIndex: idx,
week: loc.w,
dayOfWeek: loc.d,
sectionFrom: pairFrom,
sectionTo: pairFrom + 1,
}, true
}
}
continue
}
// 3. Free 处理:必须严格满足“奇数起点双节对齐位”。
// 3.1 起点必须是奇数节1/3/5/7/9/11
// 3.2 且后一节可用;不允许偶数起点(如 8-9跨对齐块。
if loc.s%2 == 0 {
continue
}
if loc.s >= 12 || !g.isAvailable(loc.w, loc.d, loc.s+1) {
continue
}
return planningSlotCandidate{
coordIndex: idx,
week: loc.w,
dayOfWeek: loc.d,
sectionFrom: loc.s,
sectionTo: loc.s + 1,
}, true
}
return planningSlotCandidate{}, false
}
// computeAllocation 根据当前时间格与策略,为每个任务块计算建议落位时间。
//
// 职责边界:
// 1. 负责“粗排落位”与“内存占位状态更新”;
// 2. 不负责持久化写库(由 service/dao 层负责);
// 3. 不负责最终展示结构转换(由 conv 层负责)。
//
// 失败语义:
// 1. 返回 TimeNotEnoughForAutoScheduling 表示“时间片总量或连续性不足”;
// 2. 返回 nil error 表示所有 items 都已成功回填 EmbeddedTime。
func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([]model.TaskClassItem, error) {
if len(items) == 0 {
return items, nil
}
// 1. 预处理可用坐标快照,并做容量下限校验(每个任务默认至少 2 节)。
coords := g.getAllAvailable()
totalAvailable := len(coords)
totalRequired := len(items) * 2
if totalAvailable < totalRequired {
return nil, respond.TimeNotEnoughForAutoScheduling
}
// 2. 计算间隔策略:
// 2.1 rapid沿用“尽快塞满”的线性前进
// 2.2 steady不再只靠 gap 跳格子,而是结合目标位置、单日负载、同科分散和缓冲保留做候选打分。
gap := 0
if strategy == "steady" {
gap = (totalAvailable - totalRequired) / (len(items) + 1)
}
// 3. 线性分配主循环:
// 3.1 cursor 是逻辑切片游标(不是物理节次指针);
// 3.2 每次成功落位后,按“命中索引 + 占用长度 + gap”推进
// 3.3 若当前位置不满足约束(例如后继节被占),继续向后扫描,不降级为 1 节。
cursor := gap
lastPlacedIndex := -1
placedDayOrdinals := make([]int, 0, len(items))
for i := range items {
var (
candidate planningSlotCandidate
found bool
)
if strategy == "steady" {
targetCursor := computeSteadyTargetCursor(totalAvailable, len(items), i)
candidate, found = g.chooseSteadyCandidate(coords, targetCursor, placedDayOrdinals)
} else {
if cursor >= totalAvailable {
break
}
candidate, found = g.findNextCandidateFromCursor(coords, cursor)
}
if !found {
break
}
// 5. 回填任务块建议时间。
items[i].EmbeddedTime = &model.TargetTime{
SectionFrom: candidate.sectionFrom,
SectionTo: candidate.sectionTo,
Week: candidate.week,
DayOfWeek: candidate.dayOfWeek,
}
// 6. 写入内存占位状态:
// 6.1 这是后续候选判断的真实依据;
// 6.2 失败兜底:纯内存操作无外部 IO不存在部分提交问题。
for sec := candidate.sectionFrom; sec <= candidate.sectionTo; sec++ {
g.setNode(candidate.week, candidate.dayOfWeek, sec, slotNode{Status: Occupied})
}
// 7. 推进游标并记录成功位置。
slotLen := candidate.sectionTo - candidate.sectionFrom + 1
if strategy != "steady" {
cursor = candidate.coordIndex + slotLen + gap
}
placedDayOrdinals = append(placedDayOrdinals, planningDayOrdinal(candidate.week, candidate.dayOfWeek))
lastPlacedIndex = i
}
// 8. 完整性校验:
// 8.1 只要有任一任务未落位,就返回统一的“时间不足”错误;
// 8.2 避免出现“部分任务有时间、部分任务为空”的半成品结果。
if lastPlacedIndex < len(items)-1 {
return nil, respond.TimeNotEnoughForAutoScheduling
}
return items, nil
}