Version: 0.9.75.dev.260505
后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
This commit is contained in:
129
backend/services/agent/conv/schedule_preview.go
Normal file
129
backend/services/agent/conv/schedule_preview.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// ScheduleStateToPreview 将 agent 的 ScheduleState 转换为前端预览缓存格式。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做数据格式转换,不做业务逻辑;
|
||||
// 2. 将每个 ScheduleTask 的每个 TaskSlot 转为一条 HybridScheduleEntry;
|
||||
// 3. Day → (Week, DayOfWeek) 通过 ScheduleState.DayToWeekDay 转换;
|
||||
// 4. 转换失败的 slot(day_index 无效)静默跳过。
|
||||
func ScheduleStateToPreview(
|
||||
state *schedule.ScheduleState,
|
||||
userID int,
|
||||
conversationID string,
|
||||
taskClassIDs []int,
|
||||
summary string,
|
||||
) *model.SchedulePlanPreviewCache {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries := make([]model.HybridScheduleEntry, 0, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
// 待安排且无位置的任务不生成 entry。
|
||||
if schedule.IsPendingTask(*t) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, slot := range t.Slots {
|
||||
week, dayOfWeek, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := model.HybridScheduleEntry{
|
||||
Week: week,
|
||||
DayOfWeek: dayOfWeek,
|
||||
SectionFrom: slot.SlotStart,
|
||||
SectionTo: slot.SlotEnd,
|
||||
Name: t.Name,
|
||||
}
|
||||
|
||||
// Type 映射。
|
||||
if t.Source == "event" {
|
||||
if t.EventType != "" {
|
||||
entry.Type = t.EventType
|
||||
} else {
|
||||
entry.Type = "course"
|
||||
}
|
||||
} else {
|
||||
entry.Type = "task"
|
||||
}
|
||||
|
||||
// Status 映射:existing 不变,suggested / 兼容建议态统一输出为 suggested。
|
||||
if shouldMarkSuggestedInPreview(*t) {
|
||||
entry.Status = "suggested"
|
||||
} else {
|
||||
entry.Status = "existing"
|
||||
}
|
||||
|
||||
// ID 映射。
|
||||
if t.Source == "event" {
|
||||
entry.EventID = t.SourceID
|
||||
} else {
|
||||
entry.TaskItemID = t.SourceID
|
||||
entry.TaskClassID = t.TaskClassID
|
||||
// 嵌入任务:将宿主课程的 source_id(即 event_id)桥接到 EventID,
|
||||
// 供前端作为 embed_course_event_id 传递给 BatchApplyPlans 做冲突豁免。
|
||||
if t.EmbedHost != nil {
|
||||
if host := state.TaskByStateID(*t.EmbedHost); host != nil {
|
||||
entry.EventID = host.SourceID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 嵌入与阻塞语义。
|
||||
entry.CanBeEmbedded = t.CanEmbed
|
||||
if t.Source == "event" && t.CanEmbed && t.EmbeddedBy == nil {
|
||||
// 可嵌入且当前无嵌入任务 → 不阻塞 suggested 占位。
|
||||
entry.BlockForSuggested = false
|
||||
} else {
|
||||
entry.BlockForSuggested = true
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成摘要(若调用方未提供)。
|
||||
if summary == "" {
|
||||
existingCount := 0
|
||||
suggestedCount := 0
|
||||
for _, e := range entries {
|
||||
if e.Status == "existing" {
|
||||
existingCount++
|
||||
} else {
|
||||
suggestedCount++
|
||||
}
|
||||
}
|
||||
summary = fmt.Sprintf("共 %d 个日程条目,其中已确定 %d 个,新安排 %d 个。", len(entries), existingCount, suggestedCount)
|
||||
}
|
||||
|
||||
return &model.SchedulePlanPreviewCache{
|
||||
UserID: userID,
|
||||
ConversationID: conversationID,
|
||||
Summary: summary,
|
||||
HybridEntries: entries,
|
||||
TaskClassIDs: taskClassIDs,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// shouldMarkSuggestedInPreview 判断某条 ScheduleTask 在预览层是否应标记为 suggested。
|
||||
//
|
||||
// 规则说明:
|
||||
// 1. 新语义下,显式 suggested 直接输出为建议态;
|
||||
// 2. 兼容旧快照:pending+Slots、existing+Duration>0 的 task_item 也继续按 suggested 输出;
|
||||
// 3. 这样前端预览口径可以在迁移期保持稳定,不会因为状态枚举切换而抖动。
|
||||
func shouldMarkSuggestedInPreview(t schedule.ScheduleTask) bool {
|
||||
return schedule.IsSuggestedTask(t)
|
||||
}
|
||||
353
backend/services/agent/conv/schedule_provider.go
Normal file
353
backend/services/agent/conv/schedule_provider.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
baseconv "github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// ScheduleProvider 实现 model.ScheduleStateProvider 接口。
|
||||
// 通过 DAO 层加载用户的日程和任务数据,调用 LoadScheduleState 构建内存状态。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责"从 DB 查数据 + 调 LoadScheduleState 转换",不含业务逻辑;
|
||||
// 2. 不负责缓存(由上层 Service 决定是否缓存);
|
||||
// 3. 不负责 Diff 和持久化(由 Confirm 流程负责)。
|
||||
type ScheduleProvider struct {
|
||||
scheduleDAO *dao.ScheduleDAO
|
||||
taskClassDAO *dao.TaskClassDAO
|
||||
}
|
||||
|
||||
// NewScheduleProvider 创建 ScheduleProvider。
|
||||
func NewScheduleProvider(scheduleDAO *dao.ScheduleDAO, taskClassDAO *dao.TaskClassDAO) *ScheduleProvider {
|
||||
return &ScheduleProvider{
|
||||
scheduleDAO: scheduleDAO,
|
||||
taskClassDAO: taskClassDAO,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadScheduleState 实现 model.ScheduleStateProvider 接口。
|
||||
//
|
||||
// 窗口策略:
|
||||
// 1. 优先从 task class 的 StartDate/EndDate 推算规划窗口,覆盖粗排所需的完整日期范围;
|
||||
// 2. task class 无日期信息时,降级到当前周 7 天(兼容普通查询场景)。
|
||||
//
|
||||
// 日程加载策略:对窗口内每周分别调用 GetUserWeeklySchedule 并合并结果。
|
||||
func (p *ScheduleProvider) LoadScheduleState(ctx context.Context, userID int) (*schedule.ScheduleState, error) {
|
||||
// 1. 加载用户所有任务类(含 Items 预加载)。
|
||||
taskClasses, err := p.loadCompleteTaskClasses(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 全量读场景保留“当前周兜底”,兼容“只看本周课表/微调”类请求。
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, true)
|
||||
}
|
||||
|
||||
// LoadScheduleStateForTaskClasses 按“本轮请求的任务类范围”加载 ScheduleState。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 负责:让粗排 / Execute 首次读取的 DayMapping 与本轮 task_class_ids 保持同一时间窗口;
|
||||
// 2. 不负责:裁掉窗口内已有的 existing/suggested 阻塞物,这部分仍由日程加载主流程统一保留;
|
||||
// 3. 失败策略:若 task_class_ids 为空,则退回全量加载,避免调用方额外分支。
|
||||
func (p *ScheduleProvider) LoadScheduleStateForTaskClasses(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClassIDs []int,
|
||||
) (*schedule.ScheduleState, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return p.LoadScheduleState(ctx, userID)
|
||||
}
|
||||
|
||||
taskClasses, err := p.loadCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. 粗排/主动编排场景必须严格按任务类时间窗加载;
|
||||
// 2. 若任务类缺少起止日期,则返回错误,交给上层 ask_user 补齐,而不是静默退回当前周。
|
||||
return p.loadScheduleStateWithTaskClasses(ctx, userID, taskClasses, false)
|
||||
}
|
||||
|
||||
// loadScheduleStateWithTaskClasses 负责把“指定任务类集合”装配成可操作的 ScheduleState。
|
||||
//
|
||||
// 步骤说明:
|
||||
// 1. 先根据传入 taskClasses 计算 DayMapping 窗口,保证粗排坐标能映射回 day_index;
|
||||
// 2. 若窗口无法从任务类日期推导,则退回当前周 7 天,兼容普通查询场景;
|
||||
// 3. 再按窗口覆盖的周批量拉取 existing schedules,与 taskClasses 一起交给 LoadScheduleState 统一建模。
|
||||
func (p *ScheduleProvider) loadScheduleStateWithTaskClasses(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClasses []model.TaskClass,
|
||||
allowCurrentWeekFallback bool,
|
||||
) (*schedule.ScheduleState, error) {
|
||||
// 1. 确定规划窗口:优先使用 task class 日期范围,降级到当前周。
|
||||
windowDays, weeks := buildWindowFromTaskClasses(taskClasses)
|
||||
if len(windowDays) == 0 {
|
||||
if !allowCurrentWeekFallback {
|
||||
return nil, fmt.Errorf("任务类缺少有效时间窗:请补充 start_date/end_date 后再进行智能编排")
|
||||
}
|
||||
var err error
|
||||
windowDays, weeks, err = buildCurrentWeekWindow()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按周加载日程(含 Event + EmbeddedTask 预加载)。
|
||||
var allSchedules []model.Schedule
|
||||
for _, w := range weeks {
|
||||
weekSchedules, err := p.scheduleDAO.GetUserWeeklySchedule(ctx, userID, w)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载用户周日程失败 week=%d: %w", w, err)
|
||||
}
|
||||
allSchedules = append(allSchedules, weekSchedules...)
|
||||
}
|
||||
|
||||
// 3. 构建额外 item category 映射。
|
||||
extraItemCategories := buildExtraItemCategories(allSchedules, taskClasses)
|
||||
|
||||
// 4. 调用已有的 LoadScheduleState 构建内存状态。
|
||||
return LoadScheduleState(allSchedules, taskClasses, extraItemCategories, windowDays), nil
|
||||
}
|
||||
|
||||
// buildWindowFromTaskClasses 从 task class 的 StartDate/EndDate 推算规划窗口。
|
||||
//
|
||||
// 返回值:
|
||||
// - windowDays:窗口内每天的 (week, dayOfWeek) 有序列表;
|
||||
// - weeks:窗口覆盖的周号(去重、升序),供按周加载日程使用;
|
||||
// - 若无有效日期信息,返回空切片,调用方应降级到默认窗口。
|
||||
func buildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) {
|
||||
minWeek, minDay := 0, 0
|
||||
maxWeek, maxDay := 0, 0
|
||||
hasWindow := false
|
||||
|
||||
for _, tc := range taskClasses {
|
||||
// 1. 先要求任务类具备完整且合法的起止日期,避免坏数据把整轮窗口拖坏。
|
||||
// 2. 再逐条做绝对日期 -> 相对周/天转换;转换失败的任务类直接忽略,不影响其余合法任务类。
|
||||
// 3. 只有至少一条任务类成功进入窗口后,才返回有效 DayMapping。
|
||||
if tc.StartDate == nil || tc.EndDate == nil || tc.EndDate.Before(*tc.StartDate) {
|
||||
continue
|
||||
}
|
||||
startWeek, startDay, err := baseconv.RealDateToRelativeDate(tc.StartDate.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
endWeek, endDay, err := baseconv.RealDateToRelativeDate(tc.EndDate.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !hasWindow || isRelativeDateBefore(startWeek, startDay, minWeek, minDay) {
|
||||
minWeek, minDay = startWeek, startDay
|
||||
}
|
||||
if !hasWindow || isRelativeDateBefore(maxWeek, maxDay, endWeek, endDay) {
|
||||
maxWeek, maxDay = endWeek, endDay
|
||||
}
|
||||
hasWindow = true
|
||||
}
|
||||
if !hasWindow {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
weeksSet := make(map[int]bool)
|
||||
w, d := minWeek, minDay
|
||||
for {
|
||||
windowDays = append(windowDays, WindowDay{Week: w, DayOfWeek: d})
|
||||
weeksSet[w] = true
|
||||
if w == maxWeek && d == maxDay {
|
||||
break
|
||||
}
|
||||
d++
|
||||
if d > 7 {
|
||||
d = 1
|
||||
w++
|
||||
}
|
||||
if w > maxWeek+1 { // 防止因日期转换异常导致无限循环
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
weeks = make([]int, 0, len(weeksSet))
|
||||
for wk := range weeksSet {
|
||||
weeks = append(weeks, wk)
|
||||
}
|
||||
sort.Ints(weeks)
|
||||
return windowDays, weeks
|
||||
}
|
||||
|
||||
// BuildWindowFromTaskClasses 暴露任务类时间窗计算给 RPC provider 复用。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只复用老 DAO provider 的窗口推导算法,保证迁移前后 day_mapping 口径一致;
|
||||
// 2. 不读取数据库、不调用 RPC;
|
||||
// 3. 无有效日期时返回空切片,由调用方决定是否降级当前周。
|
||||
func BuildWindowFromTaskClasses(taskClasses []model.TaskClass) (windowDays []WindowDay, weeks []int) {
|
||||
return buildWindowFromTaskClasses(taskClasses)
|
||||
}
|
||||
|
||||
// buildCurrentWeekWindow 构造“当前周 7 天”的兜底窗口。
|
||||
func buildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) {
|
||||
now := time.Now()
|
||||
currentWeek, _, err := baseconv.RealDateToRelativeDate(now.Format(baseconv.DateFormat))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析当前日期失败: %w", err)
|
||||
}
|
||||
windowDays = make([]WindowDay, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
windowDays[i] = WindowDay{Week: currentWeek, DayOfWeek: i + 1}
|
||||
}
|
||||
return windowDays, []int{currentWeek}, nil
|
||||
}
|
||||
|
||||
// BuildCurrentWeekWindow 暴露当前周兜底窗口给 RPC provider 复用。
|
||||
func BuildCurrentWeekWindow() (windowDays []WindowDay, weeks []int, err error) {
|
||||
return buildCurrentWeekWindow()
|
||||
}
|
||||
|
||||
// isRelativeDateBefore 比较两个“相对周/天”坐标的先后关系。
|
||||
func isRelativeDateBefore(leftWeek, leftDay, rightWeek, rightDay int) bool {
|
||||
if leftWeek != rightWeek {
|
||||
return leftWeek < rightWeek
|
||||
}
|
||||
return leftDay < rightDay
|
||||
}
|
||||
|
||||
// loadCompleteTaskClasses 批量加载用户所有任务类(含 Items 预加载)。
|
||||
func (p *ScheduleProvider) loadCompleteTaskClasses(ctx context.Context, userID int) ([]model.TaskClass, error) {
|
||||
basicClasses, err := p.taskClassDAO.GetUserTaskClasses(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载用户任务类失败: %w", err)
|
||||
}
|
||||
if len(basicClasses) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ids := make([]int, len(basicClasses))
|
||||
for i, tc := range basicClasses {
|
||||
ids[i] = tc.ID
|
||||
}
|
||||
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载完整任务类失败: %w", err)
|
||||
}
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
// loadCompleteTaskClassesByIDs 批量加载指定任务类(含 Items 预加载)。
|
||||
func (p *ScheduleProvider) loadCompleteTaskClassesByIDs(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
taskClassIDs []int,
|
||||
) ([]model.TaskClass, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载指定任务类失败: %w", err)
|
||||
}
|
||||
return complete, nil
|
||||
}
|
||||
|
||||
// LoadTaskClassMetas 加载指定任务类的约束元数据(不含 Items、不含日程),供 Plan 阶段提前消费。
|
||||
func (p *ScheduleProvider) LoadTaskClassMetas(ctx context.Context, userID int, taskClassIDs []int) ([]schedule.TaskClassMeta, error) {
|
||||
if len(taskClassIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
complete, err := p.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, taskClassIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载任务类元数据失败: %w", err)
|
||||
}
|
||||
return TaskClassesToScheduleMetas(complete), nil
|
||||
}
|
||||
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// buildExtraItemCategories 从已有日程中提取不属于给定 taskClasses 的 task event 的 category 映射。
|
||||
// 当加载全部 taskClass 时,通常返回空 map。
|
||||
func buildExtraItemCategories(schedules []model.Schedule, taskClasses []model.TaskClass) map[int]string {
|
||||
knownItemIDs := make(map[int]bool)
|
||||
for _, tc := range taskClasses {
|
||||
for _, item := range tc.Items {
|
||||
knownItemIDs[item.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
categories := make(map[int]string)
|
||||
for _, s := range schedules {
|
||||
if s.Event == nil || s.Event.Type != "task" || s.Event.RelID == nil {
|
||||
continue
|
||||
}
|
||||
itemID := *s.Event.RelID
|
||||
if !knownItemIDs[itemID] {
|
||||
categories[itemID] = "任务"
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
// BuildExtraItemCategories 暴露额外任务分类兜底映射给 RPC provider 复用。
|
||||
func BuildExtraItemCategories(schedules []model.Schedule, taskClasses []model.TaskClass) map[int]string {
|
||||
return buildExtraItemCategories(schedules, taskClasses)
|
||||
}
|
||||
|
||||
// TaskClassesToScheduleMetas 把完整任务类转换成工具层约束元数据。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做字段映射,不筛选 pending item;
|
||||
// 2. DAO provider 与 RPC provider 共用,避免迁移后 Plan 阶段元数据口径分裂;
|
||||
// 3. nil 指针字段按工具层零值处理。
|
||||
func TaskClassesToScheduleMetas(taskClasses []model.TaskClass) []schedule.TaskClassMeta {
|
||||
metas := make([]schedule.TaskClassMeta, 0, len(taskClasses))
|
||||
for _, tc := range taskClasses {
|
||||
meta := schedule.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: derefString(tc.Name),
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.ExcludedDaysOfWeek != nil {
|
||||
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.SubjectType != nil {
|
||||
meta.SubjectType = *tc.SubjectType
|
||||
}
|
||||
if tc.DifficultyLevel != nil {
|
||||
meta.DifficultyLevel = *tc.DifficultyLevel
|
||||
}
|
||||
if tc.CognitiveIntensity != nil {
|
||||
meta.CognitiveIntensity = *tc.CognitiveIntensity
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
return metas
|
||||
}
|
||||
631
backend/services/agent/conv/schedule_state.go
Normal file
631
backend/services/agent/conv/schedule_state.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// WindowDay 表示排课窗口中的一天(相对周 + 周几)。
|
||||
type WindowDay struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
}
|
||||
|
||||
// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 agent 工具层可直接操作的 ScheduleState。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责数据映射与状态归一,不做数据库读写;
|
||||
// 2. 同时兼容三种“任务已落位”信号:event.rel_id、schedules.embedded_task_id、task_item.embedded_time;
|
||||
// 3. 对嵌入课程任务优先判定为 existing,避免误挂回 pending。
|
||||
func LoadScheduleState(
|
||||
schedules []model.Schedule,
|
||||
taskClasses []model.TaskClass,
|
||||
extraItemCategories map[int]string,
|
||||
windowDays []WindowDay,
|
||||
) *schedule.ScheduleState {
|
||||
state := &schedule.ScheduleState{
|
||||
Window: schedule.ScheduleWindow{
|
||||
TotalDays: len(windowDays),
|
||||
DayMapping: make([]schedule.DayMapping, len(windowDays)),
|
||||
},
|
||||
Tasks: make([]schedule.ScheduleTask, 0),
|
||||
}
|
||||
|
||||
// 1. 构建 day_index 与 (week, day_of_week) 的双向转换基础索引。
|
||||
dayLookup := make(map[[2]int]int, len(windowDays))
|
||||
for i, wd := range windowDays {
|
||||
dayIndex := i + 1
|
||||
state.Window.DayMapping[i] = schedule.DayMapping{
|
||||
DayIndex: dayIndex,
|
||||
Week: wd.Week,
|
||||
DayOfWeek: wd.DayOfWeek,
|
||||
}
|
||||
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = dayIndex
|
||||
}
|
||||
|
||||
// 2. 构建 task_item -> 分类名映射。
|
||||
// 2.1 先放 extraItemCategories(低优先级,兜底);
|
||||
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
|
||||
itemCategoryLookup := make(map[int]string)
|
||||
itemOrderLookup := buildTaskItemOrderLookup(taskClasses)
|
||||
for id, name := range extraItemCategories {
|
||||
itemCategoryLookup[id] = name
|
||||
}
|
||||
for _, tc := range taskClasses {
|
||||
catName := "任务"
|
||||
if tc.Name != nil && *tc.Name != "" {
|
||||
catName = *tc.Name
|
||||
}
|
||||
for _, item := range tc.Items {
|
||||
itemCategoryLookup[item.ID] = catName
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 先把 schedules 聚合成 event 任务(existing)。
|
||||
type slotGroup struct {
|
||||
week int
|
||||
dayOfWeek int
|
||||
sections []int
|
||||
}
|
||||
eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位
|
||||
eventInfo := make(map[int]*model.ScheduleEvent)
|
||||
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.Event == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := eventInfo[s.EventID]; !exists {
|
||||
eventInfo[s.EventID] = s.Event
|
||||
}
|
||||
|
||||
groups := eventSlotMap[s.EventID]
|
||||
found := false
|
||||
for gi := range groups {
|
||||
if groups[gi].week == s.Week && groups[gi].dayOfWeek == s.DayOfWeek {
|
||||
groups[gi].sections = append(groups[gi].sections, s.Section)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups = append(groups, slotGroup{
|
||||
week: s.Week,
|
||||
dayOfWeek: s.DayOfWeek,
|
||||
sections: []int{s.Section},
|
||||
})
|
||||
}
|
||||
eventSlotMap[s.EventID] = groups
|
||||
}
|
||||
|
||||
nextStateID := 1
|
||||
eventStateIDs := make(map[int]int) // eventID -> stateID
|
||||
for eventID, groups := range eventSlotMap {
|
||||
event := eventInfo[eventID]
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
category := "课程"
|
||||
if event.Type == "task" {
|
||||
category = "任务"
|
||||
if event.RelID != nil {
|
||||
if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" {
|
||||
category = cat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locked := event.Type == "course" && !event.CanBeEmbedded
|
||||
var slots []schedule.TaskSlot
|
||||
for _, g := range groups {
|
||||
if len(g.sections) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Ints(g.sections)
|
||||
start, end := g.sections[0], g.sections[0]
|
||||
for _, sec := range g.sections[1:] {
|
||||
if sec == end+1 {
|
||||
end = sec
|
||||
continue
|
||||
}
|
||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||
slots = append(slots, schedule.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||||
}
|
||||
start, end = sec, sec
|
||||
}
|
||||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||||
slots = append(slots, schedule.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||||
}
|
||||
}
|
||||
sort.Slice(slots, func(i, j int) bool {
|
||||
if slots[i].Day != slots[j].Day {
|
||||
return slots[i].Day < slots[j].Day
|
||||
}
|
||||
return slots[i].SlotStart < slots[j].SlotStart
|
||||
})
|
||||
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "event",
|
||||
SourceID: eventID,
|
||||
Name: event.Name,
|
||||
Category: category,
|
||||
Status: "existing",
|
||||
Locked: locked,
|
||||
Slots: slots,
|
||||
CanEmbed: event.CanBeEmbedded,
|
||||
EventType: event.Type,
|
||||
})
|
||||
eventStateIDs[eventID] = stateID
|
||||
nextStateID++
|
||||
}
|
||||
|
||||
// 4. 构建 task_item 占位索引(后续 pending 判定优先用这两个索引短路)。
|
||||
// 4.1 event.rel_id 占位:该 item 已有 task event;
|
||||
// 4.2 schedules.embedded_task_id 占位:该 item 已嵌入到课程槽位。
|
||||
itemIDToTaskEventStateID := make(map[int]int)
|
||||
for eventID, stateID := range eventStateIDs {
|
||||
event := eventInfo[eventID]
|
||||
if event == nil || event.Type != "task" || event.RelID == nil {
|
||||
continue
|
||||
}
|
||||
itemIDToTaskEventStateID[*event.RelID] = stateID
|
||||
}
|
||||
|
||||
itemIDToEmbedHostStateID := make(map[int]int)
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.EmbeddedTaskID == nil {
|
||||
continue
|
||||
}
|
||||
hostStateID, ok := eventStateIDs[s.EventID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemIDToEmbedHostStateID[*s.EmbeddedTaskID] = hostStateID
|
||||
}
|
||||
|
||||
// 5. 处理 task_items:
|
||||
// 5.1 先消化 existing(task event / 课程嵌入 / embedded_time);
|
||||
// 5.2 剩余条目再按 status 判 pending。
|
||||
itemStateIDs := make(map[int]int) // task_item_id -> stateID
|
||||
for _, tc := range taskClasses {
|
||||
catName := "任务"
|
||||
if tc.Name != nil && *tc.Name != "" {
|
||||
catName = *tc.Name
|
||||
}
|
||||
defaultDuration := estimateTaskItemDuration(tc)
|
||||
pendingCount := 0
|
||||
|
||||
for _, item := range tc.Items {
|
||||
if stateID, ok := itemIDToTaskEventStateID[item.ID]; ok {
|
||||
itemStateIDs[item.ID] = stateID
|
||||
continue
|
||||
}
|
||||
|
||||
if hostStateID, ok := itemIDToEmbedHostStateID[item.ID]; ok {
|
||||
hostSlots := []schedule.TaskSlot(nil)
|
||||
if hostTask := state.TaskByStateID(hostStateID); hostTask != nil {
|
||||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "existing",
|
||||
Slots: hostSlots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
continue
|
||||
}
|
||||
|
||||
if slots, ok := slotsFromTargetTime(item.EmbeddedTime, dayLookup); ok {
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "existing",
|
||||
Slots: slots,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
continue
|
||||
}
|
||||
|
||||
if !isTaskItemPending(item) {
|
||||
continue
|
||||
}
|
||||
|
||||
stateID := nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: stateID,
|
||||
Source: "task_item",
|
||||
SourceID: item.ID,
|
||||
Name: taskItemName(item),
|
||||
Category: catName,
|
||||
Status: "pending",
|
||||
Duration: defaultDuration,
|
||||
CategoryID: tc.ID,
|
||||
TaskClassID: tc.ID,
|
||||
TaskOrder: itemOrderLookup[item.ID],
|
||||
})
|
||||
itemStateIDs[item.ID] = stateID
|
||||
nextStateID++
|
||||
pendingCount++
|
||||
}
|
||||
|
||||
// 仅当该任务类仍有 pending item 时,才把约束暴露给 LLM。
|
||||
if pendingCount > 0 {
|
||||
meta := schedule.TaskClassMeta{
|
||||
ID: tc.ID,
|
||||
Name: catName,
|
||||
}
|
||||
if tc.Strategy != nil {
|
||||
meta.Strategy = *tc.Strategy
|
||||
}
|
||||
if tc.TotalSlots != nil {
|
||||
meta.TotalSlots = *tc.TotalSlots
|
||||
}
|
||||
if tc.AllowFillerCourse != nil {
|
||||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||||
}
|
||||
if tc.ExcludedSlots != nil {
|
||||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||||
}
|
||||
if tc.ExcludedDaysOfWeek != nil {
|
||||
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
|
||||
}
|
||||
if tc.StartDate != nil {
|
||||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.EndDate != nil {
|
||||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||||
}
|
||||
if tc.SubjectType != nil {
|
||||
meta.SubjectType = *tc.SubjectType
|
||||
}
|
||||
if tc.DifficultyLevel != nil {
|
||||
meta.DifficultyLevel = *tc.DifficultyLevel
|
||||
}
|
||||
if tc.CognitiveIntensity != nil {
|
||||
meta.CognitiveIntensity = *tc.CognitiveIntensity
|
||||
}
|
||||
state.TaskClasses = append(state.TaskClasses, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 统一回填嵌入关系:
|
||||
// 6.1 host 记录 EmbeddedBy;
|
||||
// 6.2 guest 记录 EmbedHost;
|
||||
// 6.3 guest 强制 existing + host slots,防止“嵌入任务残留 pending”。
|
||||
for i := range schedules {
|
||||
s := &schedules[i]
|
||||
if s.EmbeddedTaskID == nil {
|
||||
continue
|
||||
}
|
||||
hostStateID, ok := eventStateIDs[s.EventID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hostTask := state.TaskByStateID(hostStateID)
|
||||
itemID := *s.EmbeddedTaskID
|
||||
|
||||
guestStateID, ok := itemStateIDs[itemID]
|
||||
if !ok {
|
||||
// 兜底:只在 schedules 层看到嵌入关系,taskClasses 不含该 item 时补建 guest。
|
||||
name := ""
|
||||
categoryID := 0
|
||||
taskClassID := 0
|
||||
if s.EmbeddedTask != nil {
|
||||
name = taskItemName(*s.EmbeddedTask)
|
||||
if s.EmbeddedTask.CategoryID != nil {
|
||||
categoryID = *s.EmbeddedTask.CategoryID
|
||||
taskClassID = *s.EmbeddedTask.CategoryID
|
||||
}
|
||||
}
|
||||
category := "任务"
|
||||
if cat, exists := itemCategoryLookup[itemID]; exists && cat != "" {
|
||||
category = cat
|
||||
}
|
||||
hostSlots := []schedule.TaskSlot(nil)
|
||||
if hostTask != nil {
|
||||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
guestStateID = nextStateID
|
||||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||||
StateID: guestStateID,
|
||||
Source: "task_item",
|
||||
SourceID: itemID,
|
||||
Name: name,
|
||||
Category: category,
|
||||
Status: "existing",
|
||||
Slots: hostSlots,
|
||||
CategoryID: categoryID,
|
||||
TaskClassID: taskClassID,
|
||||
TaskOrder: itemOrderLookup[itemID],
|
||||
})
|
||||
itemStateIDs[itemID] = guestStateID
|
||||
nextStateID++
|
||||
}
|
||||
|
||||
if hostTask != nil && hostTask.EmbeddedBy == nil {
|
||||
v := guestStateID
|
||||
hostTask.EmbeddedBy = &v
|
||||
}
|
||||
|
||||
guestTask := state.TaskByStateID(guestStateID)
|
||||
if guestTask == nil {
|
||||
continue
|
||||
}
|
||||
if guestTask.EmbedHost == nil {
|
||||
v := hostStateID
|
||||
guestTask.EmbedHost = &v
|
||||
}
|
||||
guestTask.Status = "existing"
|
||||
if hostTask != nil && len(guestTask.Slots) == 0 {
|
||||
guestTask.Slots = cloneTaskSlots(hostTask.Slots)
|
||||
}
|
||||
// existing 的 task_item 不应再携带 Duration,避免预览层误判成 suggested。
|
||||
guestTask.Duration = 0
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// isTaskItemPending 仅根据 status 判断是否应进入 pending 池。
|
||||
//
|
||||
// 说明:
|
||||
// 1. status=nil 兼容历史数据,按“未安排”处理;
|
||||
// 2. 仅 status=TaskItemStatusUnscheduled 进入 pending;
|
||||
// 3. 其它“已安排”信号由 LoadScheduleState 主流程统一判定,避免多处口径不一致。
|
||||
func isTaskItemPending(item model.TaskClassItem) bool {
|
||||
if item.Status == nil {
|
||||
return true
|
||||
}
|
||||
return *item.Status == model.TaskItemStatusUnscheduled
|
||||
}
|
||||
|
||||
// buildTaskItemOrderLookup 为每个 task_item 构建稳定顺序号。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先使用数据库里的 item.Order,保持用户或上游生成的显式顺序;
|
||||
// 2. 若历史数据缺少 order,则退回 TaskClass.Items 当前顺序,保证写工具层仍有稳定边界;
|
||||
// 3. 只负责构建运行态映射,不回写数据库。
|
||||
func buildTaskItemOrderLookup(taskClasses []model.TaskClass) map[int]int {
|
||||
lookup := make(map[int]int)
|
||||
for _, tc := range taskClasses {
|
||||
for idx, item := range tc.Items {
|
||||
order := idx + 1
|
||||
if item.Order != nil && *item.Order > 0 {
|
||||
order = *item.Order
|
||||
}
|
||||
lookup[item.ID] = order
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
// estimateTaskItemDuration 估算 pending 任务默认时长。
|
||||
//
|
||||
// 规则:若任务类声明了 total_slots,则按 total_slots / item_count 取整(最少 1);
|
||||
// 否则回退到 2 节。
|
||||
func estimateTaskItemDuration(tc model.TaskClass) int {
|
||||
duration := 2
|
||||
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
|
||||
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
|
||||
duration = d
|
||||
}
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
// taskItemName 读取任务项展示名。
|
||||
func taskItemName(item model.TaskClassItem) string {
|
||||
if item.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return *item.Content
|
||||
}
|
||||
|
||||
// slotsFromTargetTime 将 task_items.embedded_time 转换为 state 的槽位结构。
|
||||
// 若 target 为空、节次非法、或不在窗口内,返回 false。
|
||||
func slotsFromTargetTime(
|
||||
target *model.TargetTime,
|
||||
dayLookup map[[2]int]int,
|
||||
) ([]schedule.TaskSlot, bool) {
|
||||
if target == nil {
|
||||
return nil, false
|
||||
}
|
||||
if target.SectionFrom < 1 || target.SectionTo < target.SectionFrom {
|
||||
return nil, false
|
||||
}
|
||||
day, ok := dayLookup[[2]int{target.Week, target.DayOfWeek}]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return []schedule.TaskSlot{
|
||||
{
|
||||
Day: day,
|
||||
SlotStart: target.SectionFrom,
|
||||
SlotEnd: target.SectionTo,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
// ScheduleChangeType 表示两份 ScheduleState 对比后的变更类型。
|
||||
type ScheduleChangeType string
|
||||
|
||||
const (
|
||||
ChangePlace ScheduleChangeType = "place" // 从 pending 变为已放置
|
||||
ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动
|
||||
ChangeUnplace ScheduleChangeType = "unplace" // 从已放置变回 pending
|
||||
)
|
||||
|
||||
// SlotCoord 表示数据库坐标系中的单节槽位(week/day_of_week/section)。
|
||||
type SlotCoord struct {
|
||||
Week int
|
||||
DayOfWeek int
|
||||
Section int
|
||||
}
|
||||
|
||||
// ScheduleChange 描述单个任务在前后状态间的变化。
|
||||
type ScheduleChange struct {
|
||||
Type ScheduleChangeType
|
||||
StateID int
|
||||
Source string // "event" | "task_item"
|
||||
SourceID int // ScheduleEvent.ID 或 TaskClassItem.ID
|
||||
EventType string // 仅 source=event 时有意义(course/task)
|
||||
CategoryID int // 仅 source=task_item 时有意义
|
||||
Name string
|
||||
|
||||
// place/move 的新位置(展开到逐节坐标)。
|
||||
NewCoords []SlotCoord
|
||||
// move/unplace 的旧位置(展开到逐节坐标)。
|
||||
OldCoords []SlotCoord
|
||||
|
||||
// HostEventID:变更后位置对应的宿主 event(非嵌入为 0)。
|
||||
HostEventID int
|
||||
// OldHostEventID:move 时旧位置对应的宿主 event(非嵌入为 0)。
|
||||
OldHostEventID int
|
||||
}
|
||||
|
||||
// DiffScheduleState 比较 original 与 modified,返回需要持久化的变更集合。
|
||||
func DiffScheduleState(
|
||||
original *schedule.ScheduleState,
|
||||
modified *schedule.ScheduleState,
|
||||
) []ScheduleChange {
|
||||
if original == nil || modified == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
origTasks := indexByStateID(original)
|
||||
var changes []ScheduleChange
|
||||
|
||||
for i := range modified.Tasks {
|
||||
mod := &modified.Tasks[i]
|
||||
orig := origTasks[mod.StateID]
|
||||
|
||||
wasPending := orig == nil || orig.Status == "pending"
|
||||
hasSlots := len(mod.Slots) > 0
|
||||
hadSlots := orig != nil && len(orig.Slots) > 0
|
||||
|
||||
switch {
|
||||
case wasPending && hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangePlace,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
})
|
||||
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeMove,
|
||||
StateID: mod.StateID,
|
||||
Source: mod.Source,
|
||||
SourceID: mod.SourceID,
|
||||
EventType: mod.EventType,
|
||||
CategoryID: mod.CategoryID,
|
||||
Name: mod.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
NewCoords: expandToCoords(mod.Slots, modified),
|
||||
HostEventID: resolveHostEventID(mod, modified),
|
||||
OldHostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
case hadSlots && !hasSlots:
|
||||
changes = append(changes, ScheduleChange{
|
||||
Type: ChangeUnplace,
|
||||
StateID: mod.StateID,
|
||||
Source: orig.Source,
|
||||
SourceID: orig.SourceID,
|
||||
EventType: orig.EventType,
|
||||
Name: orig.Name,
|
||||
OldCoords: expandToCoords(orig.Slots, original),
|
||||
HostEventID: resolveHostEventID(orig, original),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// indexByStateID 将任务列表按 state_id 建立索引。
|
||||
func indexByStateID(state *schedule.ScheduleState) map[int]*schedule.ScheduleTask {
|
||||
m := make(map[int]*schedule.ScheduleTask, len(state.Tasks))
|
||||
for i := range state.Tasks {
|
||||
m[state.Tasks[i].StateID] = &state.Tasks[i]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// slotsEqual 判断两个压缩槽位切片是否完全一致。
|
||||
func slotsEqual(a, b []schedule.TaskSlot) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// cloneTaskSlots 深拷贝槽位切片。
|
||||
func cloneTaskSlots(src []schedule.TaskSlot) []schedule.TaskSlot {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make([]schedule.TaskSlot, len(src))
|
||||
copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
// resolveHostEventID 通过任务的 EmbedHost 反查宿主 event_id。
|
||||
// 非嵌入任务或宿主不存在时返回 0。
|
||||
func resolveHostEventID(task *schedule.ScheduleTask, state *schedule.ScheduleState) int {
|
||||
if task == nil || task.EmbedHost == nil {
|
||||
return 0
|
||||
}
|
||||
host := state.TaskByStateID(*task.EmbedHost)
|
||||
if host == nil {
|
||||
return 0
|
||||
}
|
||||
return host.SourceID
|
||||
}
|
||||
|
||||
// expandToCoords 将压缩槽位展开成逐节坐标,便于后续持久化层处理。
|
||||
func expandToCoords(slots []schedule.TaskSlot, state *schedule.ScheduleState) []SlotCoord {
|
||||
var coords []SlotCoord
|
||||
for _, slot := range slots {
|
||||
week, dow, ok := state.DayToWeekDay(slot.Day)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for sec := slot.SlotStart; sec <= slot.SlotEnd; sec++ {
|
||||
coords = append(coords, SlotCoord{Week: week, DayOfWeek: dow, Section: sec})
|
||||
}
|
||||
}
|
||||
return coords
|
||||
}
|
||||
90
backend/services/agent/conv/schedule_state_apply.go
Normal file
90
backend/services/agent/conv/schedule_state_apply.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package agentconv
|
||||
|
||||
import (
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||||
)
|
||||
|
||||
// ApplyPlacedItems 将前端提交的绝对时间放置项应用到 ScheduleState。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只修改 source=task_item 的任务,source=event 的课程不受影响;
|
||||
// 2. 不在请求中的任务保持原样(slots/status/embed 不变);
|
||||
// 3. 不校验 Slots 的业务合法性(冲突等由 execute 节点兜底);
|
||||
// 4. 返回 respond.XXX 错误,调用方可直接透传给 DealWithError。
|
||||
func ApplyPlacedItems(
|
||||
state *schedule.ScheduleState,
|
||||
items []model.SaveScheduleStatePlacedItem,
|
||||
) error {
|
||||
// 1. 构建索引。
|
||||
sourceIDToTask := make(map[int]*schedule.ScheduleTask, len(state.Tasks))
|
||||
eventSourceIDToTask := make(map[int]*schedule.ScheduleTask)
|
||||
for i := range state.Tasks {
|
||||
t := &state.Tasks[i]
|
||||
if t.Source == "task_item" {
|
||||
sourceIDToTask[t.SourceID] = t
|
||||
} else if t.Source == "event" {
|
||||
eventSourceIDToTask[t.SourceID] = t
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 去重检查。
|
||||
seen := make(map[int]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if _, dup := seen[item.TaskItemID]; dup {
|
||||
return respond.ScheduleStateDuplicateTaskItem
|
||||
}
|
||||
seen[item.TaskItemID] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 逐个处理 item。
|
||||
for _, item := range items {
|
||||
// 3.1 绝对坐标 → 相对 day_index。
|
||||
dayIndex, ok := state.WeekDayToDay(item.Week, item.DayOfWeek)
|
||||
if !ok {
|
||||
return respond.ScheduleStateInvalidCoordinates
|
||||
}
|
||||
|
||||
// 3.2 在快照中查找对应的 task_item。
|
||||
task, found := sourceIDToTask[item.TaskItemID]
|
||||
if !found {
|
||||
return respond.ScheduleStateTaskItemNotFound
|
||||
}
|
||||
|
||||
// 3.3 清除旧嵌入关系。
|
||||
if task.EmbedHost != nil {
|
||||
oldHost := state.TaskByStateID(*task.EmbedHost)
|
||||
if oldHost != nil {
|
||||
oldHost.EmbeddedBy = nil
|
||||
}
|
||||
task.EmbedHost = nil
|
||||
}
|
||||
|
||||
// 3.4 设置新嵌入关系。
|
||||
if item.EmbedCourseEventID != 0 {
|
||||
hostEvent := eventSourceIDToTask[item.EmbedCourseEventID]
|
||||
if hostEvent == nil {
|
||||
return respond.ScheduleStateEventNotFound
|
||||
}
|
||||
hostStateID := hostEvent.StateID
|
||||
guestStateID := task.StateID
|
||||
task.EmbedHost = &hostStateID
|
||||
hostEvent.EmbeddedBy = &guestStateID
|
||||
}
|
||||
|
||||
// 3.5 更新 Slots。
|
||||
task.Slots = []schedule.TaskSlot{{
|
||||
Day: dayIndex,
|
||||
SlotStart: item.StartSection,
|
||||
SlotEnd: item.EndSection,
|
||||
}}
|
||||
|
||||
// 3.6 pending → suggested。
|
||||
if task.Status == schedule.TaskStatusPending {
|
||||
task.Status = schedule.TaskStatusSuggested
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user