Files
smartmate/backend/services/agent/conv/schedule_state.go
Losita 3b6fca44a6 Version: 0.9.77.dev.260505
后端:
1.阶段 6 CP4/CP5 目录收口与共享边界纯化
- 将 backend 根目录收口为 services、client、gateway、cmd、shared 五个一级目录
- 收拢 bootstrap、inits、infra/kafka、infra/outbox、conv、respond、pkg、middleware,移除根目录旧实现与空目录
- 将 utils 下沉到 services/userauth/internal/auth,将 logic 下沉到 services/schedule/core/planning
- 将迁移期 runtime 桥接实现统一收拢到 services/runtime/{conv,dao,eventsvc,model},删除 shared/legacy 与未再被 import 的旧 service 实现
- 将 gateway/shared/respond 收口为 HTTP/Gin 错误写回适配,shared/respond 仅保留共享错误语义与状态映射
- 将 HTTP IdempotencyMiddleware 与 RateLimitMiddleware 收口到 gateway/middleware
- 将 GormCachePlugin 下沉到 shared/infra/gormcache,将共享 RateLimiter 下沉到 shared/infra/ratelimit,将 agent token budget 下沉到 services/agent/shared
- 删除 InitEino 兼容壳,收缩 cmd/internal/coreinit 仅保留旧组合壳残留域初始化语义
- 更新微服务迁移计划与桌面 checklist,补齐 CP4/CP5 当前切流点、目录终态与验证结果
- 完成 go test ./...、git diff --check 与最终真实 smoke;health、register/login、task/create+get、schedule/today、task-class/list、memory/items、agent chat/meta/timeline/context-stats 全部 200,SSE 合并结果为 CP5_OK 且 [DONE] 只有 1 个
2026-05-05 23:25:07 +08:00

632 lines
18 KiB
Go
Raw Permalink 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 agentconv
import (
"sort"
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
)
// 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 先消化 existingtask 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
// OldHostEventIDmove 时旧位置对应的宿主 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
}