后端: 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条:明确要求可验证代码必须跑单测,跑完删除测试文件 前端:无 仓库:无
458 lines
15 KiB
Go
458 lines
15 KiB
Go
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()
|
||
}
|