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条:明确要求可验证代码必须跑单测,跑完删除测试文件 前端:无 仓库:无
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
10. Prompt、State、模型交互、Graph 连线应尽量分目录/分文件管理,禁止把大段 prompt、节点逻辑、模型 helper 长期混写在同一文件中。
|
10. Prompt、State、模型交互、Graph 连线应尽量分目录/分文件管理,禁止把大段 prompt、节点逻辑、模型 helper 长期混写在同一文件中。
|
||||||
11. 若本轮任务包含“结构迁移”,最终答复中必须明确说明:本轮迁了什么、哪些旧实现仍保留、当前切流点在哪里、下一轮建议迁什么。
|
11. 若本轮任务包含“结构迁移”,最终答复中必须明确说明:本轮迁了什么、哪些旧实现仍保留、当前切流点在哪里、下一轮建议迁什么。
|
||||||
12. 若后续在 `backend/agent` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent/通用能力接入文档.md`,否则视为重构信息不完整。
|
12. 若后续在 `backend/agent` 中新增、下沉、替换任何“通用能力”,必须同步更新 `backend/agent/通用能力接入文档.md`,否则视为重构信息不完整。
|
||||||
13. 跑完单元测试后,必须删除单元测试的test.go文件,禁止把测试文件长期留在项目中。
|
13. 写完代码后,如果输入输出格式明确、逻辑可验证(如数据转换函数、解析函数、工具层操作),必须编写单元测试验证正确性。跑完之后删除测试文件(`*_test.go`),禁止把测试文件长期留在项目中。
|
||||||
|
|
||||||
## 注释规范(强制)
|
## 注释规范(强制)
|
||||||
|
|
||||||
|
|||||||
392
backend/conv/schedule_state.go
Normal file
392
backend/conv/schedule_state.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
244
backend/newAgent/tools/read_helpers.go
Normal file
244
backend/newAgent/tools/read_helpers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
457
backend/newAgent/tools/read_tools.go
Normal file
457
backend/newAgent/tools/read_tools.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
117
backend/newAgent/tools/state.go
Normal file
117
backend/newAgent/tools/state.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user