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:
Losita
2026-04-05 00:21:25 +08:00
parent 17e3615f74
commit 5c8cddb53e
5 changed files with 1211 additions and 1 deletions

View 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-120 不使用
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
}
}