Files
smartmate/backend/conv/schedule_state.go
Losita 5c8cddb53e Version: 0.8.9.dev.260405
后端:
1.新建 tools/state.go:定义 ScheduleState/ScheduleTask/TaskSlot 等工具层状态类型,含 DayToWeekDay/TaskByStateID/Clone 等辅助方法
2.新建 conv/schedule_state.go:实现 DB 模型→ScheduleState 的转换函数(LoadScheduleState)和状态对比 diff 函数(DiffScheduleState),含 Section 压缩/解压和嵌入关系解析
3.新建 tools/read_helpers.go:读工具公共辅助函数(格式化、占用统计、空闲区间计算、可嵌入查询)
4.新建 tools/read_tools.go:实现5个读工具(GetOverview/QueryRange/FindFree/ListTasks/GetTaskInfo),返回自然语言+轻结构,19个单元测试全部通过
5.更新 AGENTS.md 第13条:明确要求可验证代码必须跑单测,跑完删除测试文件
前端:无
仓库:无
2026-04-05 00:21:25 +08:00

393 lines
10 KiB
Go

package conv
import (
"sort"
"github.com/LoveLosita/smartflow/backend/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
)
// WindowDay represents a single day in the planning window.
type WindowDay struct {
Week int
DayOfWeek int
}
// ==================== Load: DB → State ====================
// LoadScheduleState builds a ScheduleState from database query results.
//
// Parameters:
// - schedules: existing Schedule records in the window (must preload Event and EmbeddedTask)
// - taskClasses: TaskClasses being scheduled (must preload Items)
// - extraItemCategories: optional TaskClassItem.ID → category name,
// for task events not belonging to the provided taskClasses
// - windowDays: ordered (week, day_of_week) pairs defining the planning window
func LoadScheduleState(
schedules []model.Schedule,
taskClasses []model.TaskClass,
extraItemCategories map[int]string,
windowDays []WindowDay,
) *newagenttools.ScheduleState {
state := &newagenttools.ScheduleState{
Window: newagenttools.ScheduleWindow{
TotalDays: len(windowDays),
DayMapping: make([]newagenttools.DayMapping, len(windowDays)),
},
Tasks: make([]newagenttools.ScheduleTask, 0),
}
// --- Step 1: Build day mapping and lookup index ---
dayLookup := make(map[[2]int]int, len(windowDays))
for i, wd := range windowDays {
idx := i + 1
state.Window.DayMapping[i] = newagenttools.DayMapping{
DayIndex: idx,
Week: wd.Week,
DayOfWeek: wd.DayOfWeek,
}
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = idx
}
// --- Step 2: Build itemID → categoryName lookup ---
// extraItemCategories first (lower priority), then taskClasses overwrites (higher priority).
itemCategoryLookup := make(map[int]string)
for id, name := range extraItemCategories {
itemCategoryLookup[id] = name
}
for _, tc := range taskClasses {
catName := "任务"
if tc.Name != nil {
catName = *tc.Name
}
for _, item := range tc.Items {
itemCategoryLookup[item.ID] = catName
}
}
// --- Step 3: Process existing schedules → existing tasks ---
type slotGroup struct {
week int
dayOfWeek int
sections []int
}
eventSlotMap := make(map[int][]slotGroup) // eventID → groups
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]
// Category
category := "课程"
if event.Type == "task" {
category = "任务"
if event.RelID != nil {
if cat, ok := itemCategoryLookup[*event.RelID]; ok {
category = cat
}
}
}
// Locked: course + not embeddable
locked := event.Type == "course" && !event.CanBeEmbedded
// Compress sections into slot ranges
var slots []newagenttools.TaskSlot
for _, g := range groups {
sort.Ints(g.sections)
start, end := g.sections[0], g.sections[0]
for _, sec := range g.sections[1:] {
if sec == end+1 {
end = sec
} else {
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
slots = append(slots, newagenttools.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, newagenttools.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
}
}
// Sort slots by day, then slot_start
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, newagenttools.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++
}
// --- Step 4: Process pending task items → pending tasks ---
itemStateIDs := make(map[int]int) // TaskClassItem.ID → stateID
for _, tc := range taskClasses {
catName := "任务"
if tc.Name != nil {
catName = *tc.Name
}
catID := tc.ID
for _, item := range tc.Items {
if item.Status == nil || *item.Status != model.TaskItemStatusUnscheduled {
continue
}
duration := 2
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
duration = d
}
}
name := ""
if item.Content != nil {
name = *item.Content
}
stateID := nextStateID
state.Tasks = append(state.Tasks, newagenttools.ScheduleTask{
StateID: stateID,
Source: "task_item",
SourceID: item.ID,
Name: name,
Category: catName,
Status: "pending",
Duration: duration,
CategoryID: catID,
})
itemStateIDs[item.ID] = stateID
nextStateID++
}
}
// --- Step 5: Resolve embed relationships ---
// Extend itemStateIDs with existing task events (rel_id → stateID)
for eventID, stateID := range eventStateIDs {
event := eventInfo[eventID]
if event.Type == "task" && event.RelID != nil {
if _, exists := itemStateIDs[*event.RelID]; !exists {
itemStateIDs[*event.RelID] = stateID
}
}
}
for i := range schedules {
s := &schedules[i]
if s.EmbeddedTaskID == nil || s.Event == nil {
continue
}
hostStateID, ok := eventStateIDs[s.EventID]
if !ok {
continue
}
guestStateID, ok := itemStateIDs[*s.EmbeddedTaskID]
if !ok {
continue
}
// Host: record which guest is embedded
hostTask := state.TaskByStateID(hostStateID)
if hostTask != nil && hostTask.EmbeddedBy == nil {
v := guestStateID
hostTask.EmbeddedBy = &v
}
// Guest: record which host it's embedded into, copy host's slots
guestTask := state.TaskByStateID(guestStateID)
if guestTask != nil && guestTask.EmbedHost == nil {
v := hostStateID
guestTask.EmbedHost = &v
}
}
return state
}
// ==================== Diff: State comparison ====================
// ScheduleChangeType classifies the type of state change.
type ScheduleChangeType string
const (
ChangePlace ScheduleChangeType = "place" // pending → placed
ChangeMove ScheduleChangeType = "move" // slots relocated
ChangeUnplace ScheduleChangeType = "unplace" // placed → pending
)
// SlotCoord is an individual section position in DB coordinates (week, day_of_week, section).
type SlotCoord struct {
Week int
DayOfWeek int
Section int
}
// ScheduleChange represents a single task change between original and modified state.
type ScheduleChange struct {
Type ScheduleChangeType
StateID int
Source string // "event" | "task_item"
SourceID int // ScheduleEvent.ID or TaskClassItem.ID
EventType string // "course" | "task" (source=event only)
CategoryID int // source=task_item only
Name string
// For place/move: new slot positions (expanded to individual sections)
NewCoords []SlotCoord
// For move/unplace: old slot positions
OldCoords []SlotCoord
}
// DiffScheduleState compares original and modified ScheduleState,
// returning the changes that need to be persisted to the database.
func DiffScheduleState(
original *newagenttools.ScheduleState,
modified *newagenttools.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 {
// Place: pending → has slots
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),
})
// Move: had slots → different slots
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),
})
// Unplace: had slots → no slots
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),
})
}
}
return changes
}
// indexByStateID creates a map of stateID → *ScheduleTask.
func indexByStateID(state *newagenttools.ScheduleState) map[int]*newagenttools.ScheduleTask {
m := make(map[int]*newagenttools.ScheduleTask, len(state.Tasks))
for i := range state.Tasks {
m[state.Tasks[i].StateID] = &state.Tasks[i]
}
return m
}
// slotsEqual compares two TaskSlot slices for equality.
func slotsEqual(a, b []newagenttools.TaskSlot) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// expandToCoords converts compressed TaskSlots to individual SlotCoords.
func expandToCoords(slots []newagenttools.TaskSlot, state *newagenttools.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
}