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:
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