From 5c8cddb53eae780d71f187ef7a9bcf3ff8368e27 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Sun, 5 Apr 2026 00:21:25 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.8.9.dev.260405=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=E6=96=B0=E5=BB=BA=20tools/state.go?= =?UTF-8?q?=EF=BC=9A=E5=AE=9A=E4=B9=89=20ScheduleState/ScheduleTask/TaskSl?= =?UTF-8?q?ot=20=E7=AD=89=E5=B7=A5=E5=85=B7=E5=B1=82=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=EF=BC=8C=E5=90=AB=20DayToWeekDay/TaskByState?= =?UTF-8?q?ID/Clone=20=E7=AD=89=E8=BE=85=E5=8A=A9=E6=96=B9=E6=B3=95=202.?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=20conv/schedule=5Fstate.go=EF=BC=9A=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20DB=20=E6=A8=A1=E5=9E=8B=E2=86=92ScheduleState=20?= =?UTF-8?q?=E7=9A=84=E8=BD=AC=E6=8D=A2=E5=87=BD=E6=95=B0=EF=BC=88LoadSched?= =?UTF-8?q?uleState=EF=BC=89=E5=92=8C=E7=8A=B6=E6=80=81=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=20diff=20=E5=87=BD=E6=95=B0=EF=BC=88DiffScheduleState=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=90=AB=20Section=20=E5=8E=8B=E7=BC=A9/=E8=A7=A3?= =?UTF-8?q?=E5=8E=8B=E5=92=8C=E5=B5=8C=E5=85=A5=E5=85=B3=E7=B3=BB=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=203.=E6=96=B0=E5=BB=BA=20tools/read=5Fhelpers.go?= =?UTF-8?q?=EF=BC=9A=E8=AF=BB=E5=B7=A5=E5=85=B7=E5=85=AC=E5=85=B1=E8=BE=85?= =?UTF-8?q?=E5=8A=A9=E5=87=BD=E6=95=B0=EF=BC=88=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E3=80=81=E5=8D=A0=E7=94=A8=E7=BB=9F=E8=AE=A1=E3=80=81=E7=A9=BA?= =?UTF-8?q?=E9=97=B2=E5=8C=BA=E9=97=B4=E8=AE=A1=E7=AE=97=E3=80=81=E5=8F=AF?= =?UTF-8?q?=E5=B5=8C=E5=85=A5=E6=9F=A5=E8=AF=A2=EF=BC=89=204.=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=20tools/read=5Ftools.go=EF=BC=9A=E5=AE=9E=E7=8E=B05?= =?UTF-8?q?=E4=B8=AA=E8=AF=BB=E5=B7=A5=E5=85=B7=EF=BC=88GetOverview/QueryR?= =?UTF-8?q?ange/FindFree/ListTasks/GetTaskInfo=EF=BC=89=EF=BC=8C=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E8=87=AA=E7=84=B6=E8=AF=AD=E8=A8=80+=E8=BD=BB?= =?UTF-8?q?=E7=BB=93=E6=9E=84=EF=BC=8C19=E4=B8=AA=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=85=A8=E9=83=A8=E9=80=9A=E8=BF=87=205.?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20AGENTS.md=20=E7=AC=AC13=E6=9D=A1=EF=BC=9A?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E8=A6=81=E6=B1=82=E5=8F=AF=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=BF=85=E9=A1=BB=E8=B7=91=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=EF=BC=8C=E8=B7=91=E5=AE=8C=E5=88=A0=E9=99=A4=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20=E5=89=8D=E7=AB=AF=EF=BC=9A=E6=97=A0=20?= =?UTF-8?q?=E4=BB=93=E5=BA=93=EF=BC=9A=E6=97=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- backend/conv/schedule_state.go | 392 +++++++++++++++++++++ backend/newAgent/tools/read_helpers.go | 244 +++++++++++++ backend/newAgent/tools/read_tools.go | 457 +++++++++++++++++++++++++ backend/newAgent/tools/state.go | 117 +++++++ 5 files changed, 1211 insertions(+), 1 deletion(-) create mode 100644 backend/conv/schedule_state.go create mode 100644 backend/newAgent/tools/read_helpers.go create mode 100644 backend/newAgent/tools/read_tools.go create mode 100644 backend/newAgent/tools/state.go diff --git a/AGENTS.md b/AGENTS.md index 55a11dc..9bb8fe8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ 10. Prompt、State、模型交互、Graph 连线应尽量分目录/分文件管理,禁止把大段 prompt、节点逻辑、模型 helper 长期混写在同一文件中。 11. 若本轮任务包含“结构迁移”,最终答复中必须明确说明:本轮迁了什么、哪些旧实现仍保留、当前切流点在哪里、下一轮建议迁什么。 12. 若后续在 `backend/agent` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent/通用能力接入文档.md`,否则视为重构信息不完整。 -13. 跑完单元测试后,必须删除单元测试的test.go文件,禁止把测试文件长期留在项目中。 +13. 写完代码后,如果输入输出格式明确、逻辑可验证(如数据转换函数、解析函数、工具层操作),必须编写单元测试验证正确性。跑完之后删除测试文件(`*_test.go`),禁止把测试文件长期留在项目中。 ## 注释规范(强制) diff --git a/backend/conv/schedule_state.go b/backend/conv/schedule_state.go new file mode 100644 index 0000000..f9dc656 --- /dev/null +++ b/backend/conv/schedule_state.go @@ -0,0 +1,392 @@ +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 +} diff --git a/backend/newAgent/tools/read_helpers.go b/backend/newAgent/tools/read_helpers.go new file mode 100644 index 0000000..4ca4102 --- /dev/null +++ b/backend/newAgent/tools/read_helpers.go @@ -0,0 +1,244 @@ +package newagenttools + +import ( + "fmt" + "sort" + "strings" +) + +// ==================== 内部辅助类型 ==================== + +// taskOnDay 表示某个任务在某一天的一个时段占用。 +// 一个任务可能出现在多天,每天可能有多段占用(如周一1-2节 + 周三3-4节)。 +type taskOnDay struct { + task *ScheduleTask + slotStart int + slotEnd int +} + +// freeRange 表示一段连续空闲区间。 +type freeRange struct { + day int + slotStart int + slotEnd int +} + +// ==================== 格式化辅助函数 ==================== + +// formatSlotRange 将时段范围格式化为人类可读的字符串。 +// start == end 时输出 "3节",否则输出 "1-2节"。 +func formatSlotRange(start, end int) string { + if start == end { + return fmt.Sprintf("%d节", start) + } + return fmt.Sprintf("%d-%d节", start, end) +} + +// formatTaskLabel 输出任务的简短标签,如 "[1]高等数学"。 +// LLM 交互时统一使用此格式引用任务。 +func formatTaskLabel(task ScheduleTask) string { + return fmt.Sprintf("[%d]%s", task.StateID, task.Name) +} + +// formatTaskLabelWithCategory 输出带类别和锁定标记的标签。 +// 如 "[1]高等数学(课程,固定)" 或 "[2]英语(课程)"。 +// 用于 get_overview 和 list_tasks 的概要输出。 +func formatTaskLabelWithCategory(task ScheduleTask) string { + label := fmt.Sprintf("[%d]%s(%s", task.StateID, task.Name, task.Category) + if task.Locked { + label += ",固定" + } + label += ")" + return label +} + +// ==================== 占用计算辅助函数 ==================== + +// getTasksOnDay 获取某天所有已安排任务的时段占用列表。 +// 返回值按 slotStart 升序排列。 +// 注意:嵌入任务(有 EmbedHost 的)也会被返回,因为它们实际占用了时段。 +func getTasksOnDay(state *ScheduleState, day int) []taskOnDay { + var result []taskOnDay + for i := range state.Tasks { + t := &state.Tasks[i] + if t.Status != "existing" && !hasSlotOnDay(t, day) { + continue + } + for _, slot := range t.Slots { + if slot.Day == day { + result = append(result, taskOnDay{ + task: t, + slotStart: slot.SlotStart, + slotEnd: slot.SlotEnd, + }) + } + } + } + // 按 slotStart 升序排列,方便逐段输出。 + sort.Slice(result, func(i, j int) bool { + return result[i].slotStart < result[j].slotStart + }) + return result +} + +// hasSlotOnDay 判断任务是否在某天有时段占用。 +func hasSlotOnDay(task *ScheduleTask, day int) bool { + for _, slot := range task.Slots { + if slot.Day == day { + return true + } + } + return false +} + +// countDayOccupied 统计某天的已占用时段总数。 +// 每个时段(slot)是独立的节次单位,一个 TaskSlot(day=1, start=1, end=2) 占 2 个时段。 +// 嵌入任务与宿主共享时段,不重复计算。 +func countDayOccupied(state *ScheduleState, day int) int { + occupied := 0 + for i := range state.Tasks { + t := &state.Tasks[i] + // 嵌入任务不重复计算占用——它和宿主共享时段。 + if t.EmbedHost != nil { + continue + } + for _, slot := range t.Slots { + if slot.Day == day { + occupied += slot.SlotEnd - slot.SlotStart + 1 + } + } + } + return occupied +} + +// slotOccupiedBy 查询某天某节被哪个任务占用。 +// 排除嵌入任务(EmbedHost != nil),因为嵌入任务与宿主共享时段。 +// 返回 nil 表示该节空闲。 +func slotOccupiedBy(state *ScheduleState, day, slot int) *ScheduleTask { + for i := range state.Tasks { + t := &state.Tasks[i] + // 嵌入任务不视为独立占用。 + if t.EmbedHost != nil { + continue + } + for _, s := range t.Slots { + if s.Day == day && slot >= s.SlotStart && slot <= s.SlotEnd { + return t + } + } + } + return nil +} + +// ==================== 空闲区间计算 ==================== + +// findFreeRangesOnDay 计算某天所有连续空闲区间。 +// 算法: +// 1. 构建 12 个时段的占用数组(排除嵌入任务,嵌入任务共享宿主时段) +// 2. 扫描连续空闲段 +// +// 返回值按 slotStart 升序排列。 +func findFreeRangesOnDay(state *ScheduleState, day int) []freeRange { + // 1. 构建占用数组:occupied[slot] = true 表示该节被占用。 + occupied := make([]bool, 13) // 下标 1-12,0 不使用 + for i := range state.Tasks { + t := &state.Tasks[i] + // 嵌入任务与宿主共享时段,不算独立占用。 + if t.EmbedHost != nil { + continue + } + for _, slot := range t.Slots { + if slot.Day == day { + for s := slot.SlotStart; s <= slot.SlotEnd; s++ { + if s >= 1 && s <= 12 { + occupied[s] = true + } + } + } + } + } + + // 2. 扫描连续空闲段。 + var ranges []freeRange + start := 0 + for s := 1; s <= 12; s++ { + if !occupied[s] { + if start == 0 { + start = s + } + } else { + if start > 0 { + ranges = append(ranges, freeRange{day: day, slotStart: start, slotEnd: s - 1}) + start = 0 + } + } + } + if start > 0 { + ranges = append(ranges, freeRange{day: day, slotStart: start, slotEnd: 12}) + } + return ranges +} + +// getEmbeddableTasks 获取所有可嵌入时段的任务列表。 +// 条件:CanEmbed == true,用于 find_free 和 get_overview 输出可嵌入位置。 +func getEmbeddableTasks(state *ScheduleState) []*ScheduleTask { + var result []*ScheduleTask + for i := range state.Tasks { + t := &state.Tasks[i] + if t.CanEmbed && len(t.Slots) > 0 { + result = append(result, t) + } + } + return result +} + +// ==================== 通用输出构建 ==================== + +// buildOverviewDayLine 构建某天的概况行。 +// 格式如:第1天:占6/12 — [1]高等数学(1-2节) [2]英语(3-4节) +// 空闲天输出如:第3天:占0/12 +func buildOverviewDayLine(state *ScheduleState, day int) string { + occupied := countDayOccupied(state, day) + tasks := getTasksOnDay(state, day) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("第%d天:占%d/12", day, occupied)) + + if len(tasks) > 0 { + sb.WriteString(" — ") + for i, td := range tasks { + if i > 0 { + sb.WriteString(" ") + } + label := formatTaskLabel(*td.task) + // 如果任务可嵌入且宿主未被嵌入,标注"可嵌入"。 + suffix := "" + if td.task.CanEmbed && td.task.EmbeddedBy == nil { + suffix = ",可嵌入" + } + sb.WriteString(fmt.Sprintf("%s(%s%s)", label, formatSlotRange(td.slotStart, td.slotEnd), suffix)) + } + } + return sb.String() +} + +// buildFreeRangeLine 格式化空闲区间行。 +// 格式如:第3天 第1-6节(6时段连续空闲) +func buildFreeRangeLine(r freeRange) string { + dur := r.slotEnd - r.slotStart + 1 + return fmt.Sprintf("第%d天 第%s(%d时段连续空闲)", r.day, formatSlotRange(r.slotStart, r.slotEnd), dur) +} + +// formatSourceName 将 source 字段转为用户可读的来源名称。 +// "event" → "课程表","task_item" → "任务"。 +// 不暴露原始 source 字段值,统一使用中文描述。 +func formatSourceName(source string) string { + switch source { + case "event": + return "课程表" + case "task_item": + return "任务" + default: + return source + } +} diff --git a/backend/newAgent/tools/read_tools.go b/backend/newAgent/tools/read_tools.go new file mode 100644 index 0000000..c48fb9f --- /dev/null +++ b/backend/newAgent/tools/read_tools.go @@ -0,0 +1,457 @@ +package newagenttools + +import ( + "fmt" + "sort" + "strings" +) + +// ==================== 读工具:LLM 只通过这些函数感知日程状态 ==================== +// 所有读工具: +// - 只读不改,不修改 state +// - 返回自然语言 + 轻结构(缩进、列表),LLM 直接理解 +// - 只报当前真实状态,不做建议/推荐/假设 +// - 不暴露 source、source_id、event_type 内部字段 + +// GetOverview 获取规划窗口的粗粒度总览,用于建立全局感知。 +// 无参数,返回整个窗口的占用统计 + 每日概况 + 可嵌入时段 + 待安排任务。 +func GetOverview(state *ScheduleState) string { + totalSlots := state.Window.TotalDays * 12 + + // 1. 统计总占用时段数(排除嵌入任务,嵌入与宿主共享时段)。 + totalOccupied := 0 + for i := range state.Tasks { + t := &state.Tasks[i] + if t.EmbedHost != nil { + continue // 嵌入任务不重复计算占用 + } + for _, slot := range t.Slots { + totalOccupied += slot.SlotEnd - slot.SlotStart + 1 + } + } + totalFree := totalSlots - totalOccupied + + // 2. 统计待安排任务数。 + pendingCount := 0 + for i := range state.Tasks { + if state.Tasks[i].Status == "pending" { + pendingCount++ + } + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("规划窗口共%d天,每天12个时段,总计%d个时段。\n", state.Window.TotalDays, totalSlots)) + sb.WriteString(fmt.Sprintf("当前已占用%d个,空闲%d个。待安排任务%d个。\n", totalOccupied, totalFree, pendingCount)) + + // 3. 逐天概况。 + sb.WriteString("\n每日概况:\n") + for day := 1; day <= state.Window.TotalDays; day++ { + sb.WriteString(buildOverviewDayLine(state, day) + "\n") + } + + // 4. 可嵌入时段汇总(单独列出,方便 LLM 快速定位)。 + embeddable := getEmbeddableTasks(state) + if len(embeddable) > 0 { + sb.WriteString("\n可嵌入时段:") + parts := make([]string, 0, len(embeddable)) + for _, t := range embeddable { + for _, slot := range t.Slots { + label := formatTaskLabel(*t) + embedStatus := "当前无嵌入任务" + if t.EmbeddedBy != nil { + guest := state.TaskByStateID(*t.EmbeddedBy) + if guest != nil { + embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name) + } + } + parts = append(parts, fmt.Sprintf("第%d天 %s(%s)", slot.Day, label, embedStatus)) + } + } + sb.WriteString(strings.Join(parts, ";") + "\n") + } + + // 5. 待安排任务汇总。 + if pendingCount > 0 { + sb.WriteString("待安排:") + pendingParts := make([]string, 0, pendingCount) + for i := range state.Tasks { + t := &state.Tasks[i] + if t.Status == "pending" { + pendingParts = append(pendingParts, fmt.Sprintf("[%d]%s(需%d时段)", t.StateID, t.Name, t.Duration)) + } + } + sb.WriteString(strings.Join(pendingParts, " ") + "\n") + } + + return sb.String() +} + +// QueryRange 查看某天(或某天某段)的细粒度占用详情。 +// day 必填,slotStart/slotEnd 选填(nil 表示查整天)。 +// 整天模式按标准段(1-2, 3-4, ..., 11-12)分组输出。 +// 指定范围模式逐节输出。 +func QueryRange(state *ScheduleState, day int, slotStart, slotEnd *int) string { + // 1. 校验 day 是否在有效范围内。 + if day < 1 || day > state.Window.TotalDays { + return fmt.Sprintf("查询失败:第%d天不在规划窗口范围内(1-%d)。", day, state.Window.TotalDays) + } + + // 2. 分两种模式:整天查询 vs 指定范围查询。 + if slotStart == nil || slotEnd == nil { + return queryRangeFullDay(state, day) + } + return queryRangeSpecific(state, day, *slotStart, *slotEnd) +} + +// queryRangeFullDay 整天查询模式:按标准段分组输出。 +// 输出格式对齐 SCHEDULE_TOOLS.md 4.2 节示例。 +func queryRangeFullDay(state *ScheduleState, day int) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("第%d天 全天:\n\n", day)) + + // 1. 按 6 个标准段输出(1-2, 3-4, 5-6, 7-8, 9-10, 11-12)。 + for start := 1; start <= 11; start += 2 { + end := start + 1 + // 查该段的占用情况,找该段内所有占用任务。 + occupants := tasksInRange(state, day, start, end) + if len(occupants) == 0 { + sb.WriteString(fmt.Sprintf("第%s:空\n", formatSlotRange(start, end))) + } else { + desc := formatOccupants(occupants) + sb.WriteString(fmt.Sprintf("第%s:%s\n", formatSlotRange(start, end), desc)) + } + } + + // 2. 附加连续空闲区摘要。 + freeRanges := findFreeRangesOnDay(state, day) + if len(freeRanges) > 0 { + sb.WriteString("\n连续空闲区:") + rangeParts := make([]string, 0, len(freeRanges)) + for _, r := range freeRanges { + dur := r.slotEnd - r.slotStart + 1 + rangeParts = append(rangeParts, fmt.Sprintf("第%s(%d时段)", formatSlotRange(r.slotStart, r.slotEnd), dur)) + } + sb.WriteString(strings.Join(rangeParts, "、") + "\n") + } + + // 3. 附加可嵌入信息(仅当该天有可嵌入时段时输出)。 + embedInfo := formatEmbedInfoForDay(state, day) + if embedInfo != "" { + sb.WriteString("可嵌入:" + embedInfo + "\n") + } + + return sb.String() +} + +// queryRangeSpecific 指定范围查询模式:逐节输出。 +func queryRangeSpecific(state *ScheduleState, day, startSlot, endSlot int) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("第%d天 第%s:\n\n", day, formatSlotRange(startSlot, endSlot))) + + freeCount := 0 + for s := startSlot; s <= endSlot; s++ { + occupant := slotOccupiedBy(state, day, s) + if occupant == nil { + sb.WriteString(fmt.Sprintf("第%d节:空\n", s)) + freeCount++ + } else { + sb.WriteString(fmt.Sprintf("第%d节:[%d]%s\n", s, occupant.StateID, occupant.Name)) + } + } + + total := endSlot - startSlot + 1 + sb.WriteString(fmt.Sprintf("\n该范围%d个时段全部空闲。\n", total)) + if freeCount < total { + // 替换"全部空闲"为实际空闲数 + sb.Reset() + // 重新构建(非全部空闲的情况不需要"该范围全部空闲") + sb.WriteString(fmt.Sprintf("第%d天 第%s:\n\n", day, formatSlotRange(startSlot, endSlot))) + for s := startSlot; s <= endSlot; s++ { + occupant := slotOccupiedBy(state, day, s) + if occupant == nil { + sb.WriteString(fmt.Sprintf("第%d节:空\n", s)) + } else { + sb.WriteString(fmt.Sprintf("第%d节:[%d]%s\n", s, occupant.StateID, occupant.Name)) + } + } + sb.WriteString(fmt.Sprintf("\n该范围%d个时段中,%d个空闲,%d个被占用。\n", total, freeCount, total-freeCount)) + } + + return sb.String() +} + +// FindFree 查找满足指定连续时段长度的空闲位置。 +// duration 必填,day 选填(nil 表示搜索全部天)。 +// 返回所有 >= duration 的空闲连续区间 + 可嵌入位置。 +func FindFree(state *ScheduleState, duration int, day *int) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("满足%d个连续空闲时段的位置:\n\n", duration)) + + // 1. 确定搜索范围。 + days := make([]int, 0) + if day != nil { + if *day < 1 || *day > state.Window.TotalDays { + return fmt.Sprintf("查询失败:第%d天不在规划窗口范围内(1-%d)。", *day, state.Window.TotalDays) + } + days = append(days, *day) + } else { + for d := 1; d <= state.Window.TotalDays; d++ { + days = append(days, d) + } + } + + // 2. 逐天查找满足条件的空闲区间。 + found := 0 + for _, d := range days { + freeRanges := findFreeRangesOnDay(state, d) + for _, r := range freeRanges { + rDur := r.slotEnd - r.slotStart + 1 + if rDur >= duration { + sb.WriteString(fmt.Sprintf("第%d天 第%s(%d时段连续空闲)\n", d, formatSlotRange(r.slotStart, r.slotEnd), rDur)) + found++ + } + } + } + + if found == 0 { + sb.WriteString("未找到满足条件的空闲时段。\n") + } + + // 3. 可嵌入位置单独列出(水课时段,可叠加任务)。 + embeddable := getEmbeddableTasks(state) + if len(embeddable) > 0 { + sb.WriteString("\n可嵌入位置(水课时段,可叠加任务):\n") + for _, t := range embeddable { + for _, slot := range t.Slots { + // 检查是否在搜索范围内。 + if day != nil && slot.Day != *day { + continue + } + embedStatus := "当前无嵌入任务" + if t.EmbeddedBy != nil { + guest := state.TaskByStateID(*t.EmbeddedBy) + if guest != nil { + embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name) + } + } + sb.WriteString(fmt.Sprintf("第%d天 第%s([%d]%s,%s)\n", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd), t.StateID, t.Name, embedStatus)) + } + } + } + + return sb.String() +} + +// ListTasks 列出任务清单,可按类别和状态过滤。 +// category 选填(nil 不过滤),status 选填(nil 默认 "all")。 +// 输出按状态分组:已安排在前,待安排在后。组内按 stateID 升序。 +func ListTasks(state *ScheduleState, category, status *string) string { + // 1. 确定过滤状态。 + statusFilter := "all" + if status != nil { + statusFilter = *status + } + + // 2. 过滤 + 分组。 + var existingTasks, pendingTasks []ScheduleTask + for i := range state.Tasks { + t := state.Tasks[i] + // 类别过滤。 + if category != nil && t.Category != *category { + continue + } + // 状态过滤。 + if statusFilter != "all" && t.Status != statusFilter { + continue + } + if t.Status == "pending" { + pendingTasks = append(pendingTasks, t) + } else { + existingTasks = append(existingTasks, t) + } + } + + // 3. 按 stateID 排序。 + sort.Slice(existingTasks, func(i, j int) bool { return existingTasks[i].StateID < existingTasks[j].StateID }) + sort.Slice(pendingTasks, func(i, j int) bool { return pendingTasks[i].StateID < pendingTasks[j].StateID }) + + // 4. 纯待安排模式:只输出待安排任务。 + if statusFilter == "pending" { + return formatPendingList(pendingTasks) + } + + // 5. 纯已安排模式:只输出已安排任务。 + if statusFilter == "existing" { + return formatExistingList(existingTasks) + } + + // 6. 全部模式:统计 + 分组输出。 + total := len(existingTasks) + len(pendingTasks) + var sb strings.Builder + sb.WriteString(fmt.Sprintf("共%d个任务,已安排%d个,待安排%d个。\n", total, len(existingTasks), len(pendingTasks))) + + if len(existingTasks) > 0 { + sb.WriteString("\n已安排:\n") + sb.WriteString(formatExistingList(existingTasks)) + } + if len(pendingTasks) > 0 { + sb.WriteString("\n待安排:\n") + sb.WriteString(formatPendingList(pendingTasks)) + } + + return sb.String() +} + +// GetTaskInfo 查询单个任务的详细信息。 +// taskID 必填,为 state 内的 state_id。 +// 不存在时返回错误信息字符串。 +func GetTaskInfo(state *ScheduleState, taskID int) string { + task := state.TaskByStateID(taskID) + if task == nil { + return fmt.Sprintf("查询失败:任务ID %d 不存在。", taskID) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("[%d]%s\n", task.StateID, task.Name)) + + // 1. 类别、状态、来源。 + statusLabel := "已安排" + if task.Status == "pending" { + statusLabel = "待安排" + } else if task.Locked { + statusLabel = "已安排(固定)" + } + sb.WriteString(fmt.Sprintf("类别:%s | 状态:%s\n", task.Category, statusLabel)) + sb.WriteString(fmt.Sprintf("来源:%s\n", formatSourceName(task.Source))) + + // 2. 可嵌入信息(仅 can_embed 任务显示)。 + if task.CanEmbed { + sb.WriteString("可嵌入:是(允许在此时段嵌入其他任务)\n") + } + + // 3. 占用时段。 + if len(task.Slots) > 0 { + sb.WriteString("占用时段:\n") + for _, slot := range task.Slots { + sb.WriteString(fmt.Sprintf(" 第%d天 第%s\n", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd))) + } + } + + // 4. 待安排任务显示需要时段数。 + if task.Status == "pending" { + sb.WriteString(fmt.Sprintf("需要时段:%d个连续时段\n", task.Duration)) + } + + // 5. 嵌入关系信息。 + if task.CanEmbed { + if task.EmbeddedBy != nil { + guest := state.TaskByStateID(*task.EmbeddedBy) + if guest != nil { + sb.WriteString(fmt.Sprintf("当前嵌入任务:[%d]%s\n", guest.StateID, guest.Name)) + } + } else { + sb.WriteString("当前嵌入任务:无\n") + } + } + if task.EmbedHost != nil { + host := state.TaskByStateID(*task.EmbedHost) + if host != nil { + sb.WriteString(fmt.Sprintf("嵌入宿主:[%d]%s\n", host.StateID, host.Name)) + } + } + + return sb.String() +} + +// ==================== 内部格式化函数 ==================== + +// tasksInRange 获取某天指定时段范围内的占用任务列表。 +// 返回在该范围内有占用的所有任务(去重,按 slotStart 排序)。 +func tasksInRange(state *ScheduleState, day, start, end int) []taskOnDay { + tasks := getTasksOnDay(state, day) + var result []taskOnDay + for _, td := range tasks { + // 判断是否有交集:任务的 [slotStart, slotEnd] 与查询范围 [start, end] 有重叠。 + if td.slotStart <= end && td.slotEnd >= start { + result = append(result, td) + } + } + return result +} + +// formatOccupants 格式化占用任务列表为紧凑描述。 +// 如 "[1]高等数学(固定)" 或 "[6]线代" +func formatOccupants(occupants []taskOnDay) string { + parts := make([]string, 0, len(occupants)) + for _, o := range occupants { + label := formatTaskLabel(*o.task) + if o.task.Locked { + parts = append(parts, label+"(固定)") + } else if o.task.CanEmbed { + parts = append(parts, label+"(可嵌入)") + } else { + parts = append(parts, label) + } + } + return strings.Join(parts, " ") +} + +// formatEmbedInfoForDay 格式化某天的可嵌入信息。 +// 返回空字符串表示该天没有可嵌入时段。 +func formatEmbedInfoForDay(state *ScheduleState, day int) string { + var parts []string + for i := range state.Tasks { + t := &state.Tasks[i] + if !t.CanEmbed { + continue + } + for _, slot := range t.Slots { + if slot.Day != day { + continue + } + label := formatTaskLabel(*t) + if t.Locked { + parts = append(parts, fmt.Sprintf("第%s已有%s(固定,不可嵌入)", formatSlotRange(slot.SlotStart, slot.SlotEnd), label)) + } else { + embedStatus := "可嵌入" + if t.EmbeddedBy != nil { + guest := state.TaskByStateID(*t.EmbeddedBy) + if guest != nil { + embedStatus = fmt.Sprintf("已嵌入[%d]%s", guest.StateID, guest.Name) + } + } + parts = append(parts, fmt.Sprintf("第%s已有%s(%s)", formatSlotRange(slot.SlotStart, slot.SlotEnd), label, embedStatus)) + } + } + } + return strings.Join(parts, ";") +} + +// formatExistingList 格式化已安排任务列表。 +// 格式如: [1]高等数学(课程,固定) — 第1天(1-2节) 第4天(1-2节) +func formatExistingList(tasks []ScheduleTask) string { + var sb strings.Builder + for _, t := range tasks { + label := formatTaskLabelWithCategory(t) + // 格式化所有时段位置。 + slotParts := make([]string, 0, len(t.Slots)) + for _, slot := range t.Slots { + slotParts = append(slotParts, fmt.Sprintf("第%d天(%s)", slot.Day, formatSlotRange(slot.SlotStart, slot.SlotEnd))) + } + sb.WriteString(fmt.Sprintf(" %s — %s\n", label, strings.Join(slotParts, " "))) + } + return sb.String() +} + +// formatPendingList 格式化待安排任务列表。 +// 格式如:[3]复习线代 — 需3个连续时段,类别:学习 +func formatPendingList(tasks []ScheduleTask) string { + var sb strings.Builder + if len(tasks) > 0 { + sb.WriteString(fmt.Sprintf("待安排任务共%d个:\n\n", len(tasks))) + } + for _, t := range tasks { + sb.WriteString(fmt.Sprintf("[%d]%s — 需%d个连续时段,类别:%s\n", t.StateID, t.Name, t.Duration, t.Category)) + } + return sb.String() +} diff --git a/backend/newAgent/tools/state.go b/backend/newAgent/tools/state.go new file mode 100644 index 0000000..0d48515 --- /dev/null +++ b/backend/newAgent/tools/state.go @@ -0,0 +1,117 @@ +package newagenttools + +// DayMapping maps a day_index to a real (week, day_of_week) coordinate. +type DayMapping struct { + DayIndex int `json:"day_index"` + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` +} + +// ScheduleWindow defines the planning window. +type ScheduleWindow struct { + TotalDays int `json:"total_days"` + DayMapping []DayMapping `json:"day_mapping"` +} + +// TaskSlot is a compressed time slot using day_index and section range. +type TaskSlot struct { + Day int `json:"day"` + SlotStart int `json:"slot_start"` + SlotEnd int `json:"slot_end"` +} + +// ScheduleTask is a unified task representation in the tool state. +// It merges existing schedules (from schedule_events) and pending tasks (from task_items) +// into one flat list that the tool layer operates on. +type ScheduleTask struct { + StateID int `json:"state_id"` + Source string `json:"source"` // "event" | "task_item" + SourceID int `json:"source_id"` // ScheduleEvent.ID or TaskClassItem.ID + Name string `json:"name"` + Category string `json:"category"` // e.g. "课程", "学习", "作业" + Status string `json:"status"` // "existing" | "pending" + Locked bool `json:"locked"` + + // Existing task: compressed slot ranges. Pending task: nil until placed. + Slots []TaskSlot `json:"slots,omitempty"` + // Pending task: required consecutive slot count. + Duration int `json:"duration,omitempty"` + // source=task_item only: TaskClass.ID for category lookup. + CategoryID int `json:"category_id,omitempty"` + // source=event only: whether this slot allows embedding other tasks. + CanEmbed bool `json:"can_embed,omitempty"` + + // Embed relationships (resolved after all tasks are loaded). + EmbeddedBy *int `json:"embedded_by,omitempty"` // host: which state_id is embedded into me + EmbedHost *int `json:"embed_host,omitempty"` // guest: which state_id's slot I'm embedded into + + // Internal: not exposed to LLM, used for flush/diff logic. + EventType string `json:"event_type,omitempty"` // "course" | "task" (source=event only) +} + +// ScheduleState is the full tool operation state. +type ScheduleState struct { + Window ScheduleWindow `json:"window"` + Tasks []ScheduleTask `json:"tasks"` +} + +// DayToWeekDay converts day_index to (week, day_of_week). +func (s *ScheduleState) DayToWeekDay(day int) (week, dayOfWeek int, ok bool) { + for _, m := range s.Window.DayMapping { + if m.DayIndex == day { + return m.Week, m.DayOfWeek, true + } + } + return 0, 0, false +} + +// WeekDayToDay converts (week, day_of_week) to day_index. +func (s *ScheduleState) WeekDayToDay(week, dayOfWeek int) (day int, ok bool) { + for _, m := range s.Window.DayMapping { + if m.Week == week && m.DayOfWeek == dayOfWeek { + return m.DayIndex, true + } + } + return 0, false +} + +// TaskByStateID finds a task by state_id. Returns nil if not found. +func (s *ScheduleState) TaskByStateID(stateID int) *ScheduleTask { + for i := range s.Tasks { + if s.Tasks[i].StateID == stateID { + return &s.Tasks[i] + } + } + return nil +} + +// Clone returns a deep copy of the ScheduleState. +func (s *ScheduleState) Clone() *ScheduleState { + if s == nil { + return nil + } + clone := &ScheduleState{ + Window: ScheduleWindow{ + TotalDays: s.Window.TotalDays, + DayMapping: make([]DayMapping, len(s.Window.DayMapping)), + }, + Tasks: make([]ScheduleTask, len(s.Tasks)), + } + copy(clone.Window.DayMapping, s.Window.DayMapping) + for i, t := range s.Tasks { + clone.Tasks[i] = t + if t.Slots != nil { + clone.Tasks[i].Slots = make([]TaskSlot, len(t.Slots)) + copy(clone.Tasks[i].Slots, t.Slots) + } + if t.EmbeddedBy != nil { + v := *t.EmbeddedBy + clone.Tasks[i].EmbeddedBy = &v + } + if t.EmbedHost != nil { + v := *t.EmbedHost + clone.Tasks[i].EmbedHost = &v + } + } + return clone +}