Files
smartmate/backend/newAgent/tools/read_tools.go
Losita cdedd3c968 Version: 0.9.5.dev.260407
后端:
1.粗排链路收口(按 task_class_ids 精确加载 ScheduleState + 规划窗口抗脏数据)
  - 更新conv/schedule_provider.go:新增 LoadScheduleStateForTaskClasses;优先按本轮任务类加载窗口;buildWindowFromTaskClasses 改为逐条过滤坏日期,避免 DayMapping 被全量任务类污染
  - 更新model/state_store.go:新增 ScopedScheduleStateProvider 可选接口
  - 更新model/graph_run_state.go:EnsureScheduleState 首次加载时优先走 scoped provider,再做 scope 裁剪
2.粗排建议态语义统一(pending/existing → pending/suggested/existing)
  - 新建tools/status.go:统一 IsPendingTask / IsSuggestedTask / IsExistingTask / scope 过滤逻辑
  - 更新node/rough_build.go:粗排回写后任务显式转 suggested;pending 统计仅看“真实 pending”
  - 更新tools/state.go:ScheduleTask.Status/Slots/Duration 注释补齐 suggested 语义
  - 更新tools/read_helpers.go + read_tools.go:overview/list_tasks/task_info 支持 suggested 展示;占用计算按“已落位任务”统一处理
  - 更新tools/write_helpers.go + write_tools.go:place/move/swap/unplace 全量切到 suggested/existing/pending 新语义
  - 更新tools/registry.go + SCHEDULE_TOOLS.md:工具描述、参数枚举、文档口径同步到 suggested 语义
  - 更新conv/schedule_preview.go:预览层统一通过 IsSuggestedTask 输出 suggested,兼容旧快照
  - 更新service/agentsvc/agent_newagent.go:预览 debug 摘要改为 pending/suggested/existing 三态统计
3.粗排调试增强
  - 更新node/rough_build.go:新增 applied/day_mapping_miss/task_item_match_miss 统计及样本日志,便于排查 placement 未落回 state 的根因
前端:无 仓库:无
2026-04-07 23:58:00 +08:00

540 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. 统计任务状态分布。
existingCount := 0
suggestedCount := 0
pendingCount := 0
for i := range state.Tasks {
task := state.Tasks[i]
switch {
case IsPendingTask(task):
pendingCount++
case IsSuggestedTask(task):
suggestedCount++
case IsExistingTask(task):
existingCount++
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("规划窗口共%d天每天12个时段总计%d个时段。\n", state.Window.TotalDays, totalSlots))
sb.WriteString(fmt.Sprintf("当前已占用%d个空闲%d个。已确定任务%d个已预排任务%d个待安排任务%d个。\n", totalOccupied, totalFree, existingCount, suggestedCount, 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 suggestedCount > 0 {
sb.WriteString("已预排:")
suggestedParts := make([]string, 0, suggestedCount)
for i := range state.Tasks {
t := &state.Tasks[i]
if IsSuggestedTask(*t) {
suggestedParts = append(suggestedParts, fmt.Sprintf("[%d]%s(%s)", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots)))
}
}
sb.WriteString(strings.Join(suggestedParts, " ") + "\n")
}
// 6. 待安排任务汇总。
if pendingCount > 0 {
sb.WriteString("待安排:")
pendingParts := make([]string, 0, pendingCount)
for i := range state.Tasks {
t := &state.Tasks[i]
if IsPendingTask(*t) {
pendingParts = append(pendingParts, fmt.Sprintf("[%d]%s(需%d时段)", t.StateID, t.Name, t.Duration))
}
}
sb.WriteString(strings.Join(pendingParts, " ") + "\n")
}
// 7. 任务类约束(排课策略与限制)。
if len(state.TaskClasses) > 0 {
sb.WriteString("\n任务类约束排课时请遵守\n")
for _, tc := range state.TaskClasses {
strategy := formatStrategy(tc.Strategy)
allow := "否"
if tc.AllowFillerCourse {
allow = "是"
}
line := fmt.Sprintf(" [%s] 策略=%s 总预算=%d节 允许嵌水课=%s", tc.Name, strategy, tc.TotalSlots, allow)
if len(tc.ExcludedSlots) > 0 {
parts := make([]string, len(tc.ExcludedSlots))
for i, s := range tc.ExcludedSlots {
parts[i] = fmt.Sprintf("%d", s)
}
line += fmt.Sprintf(" 排除时段=[%s]", strings.Join(parts, ","))
}
sb.WriteString(line + "\n")
}
}
return sb.String()
}
// formatStrategy 将 strategy 字段值转为中文描述。
func formatStrategy(strategy string) string {
switch strategy {
case "steady":
return "均匀分布"
case "rapid":
return "集中突击"
default:
if strategy == "" {
return "默认"
}
return strategy
}
}
// 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)))
total := endSlot - startSlot + 1
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))
}
}
if freeCount == total {
sb.WriteString(fmt.Sprintf("\n该范围%d个时段全部空闲。\n", total))
} else {
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, suggestedTasks, pendingTasks []ScheduleTask
for i := range state.Tasks {
t := state.Tasks[i]
// 类别过滤。
if category != nil && t.Category != *category {
continue
}
switch {
case IsPendingTask(t):
if statusFilter != "all" && statusFilter != "pending" {
continue
}
pendingTasks = append(pendingTasks, t)
case IsSuggestedTask(t):
if statusFilter != "all" && statusFilter != "suggested" {
continue
}
suggestedTasks = append(suggestedTasks, t)
default:
if statusFilter != "all" && statusFilter != "existing" {
continue
}
existingTasks = append(existingTasks, t)
}
}
// 3. 按 stateID 排序。
sort.Slice(existingTasks, func(i, j int) bool { return existingTasks[i].StateID < existingTasks[j].StateID })
sort.Slice(suggestedTasks, func(i, j int) bool { return suggestedTasks[i].StateID < suggestedTasks[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 == "suggested" {
return formatSuggestedList(suggestedTasks)
}
// 6. 纯已安排模式:只输出已安排任务。
if statusFilter == "existing" {
return formatExistingList(existingTasks)
}
// 7. 全部模式:统计 + 分组输出。
total := len(existingTasks) + len(suggestedTasks) + len(pendingTasks)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("共%d个任务已安排%d个已预排%d个待安排%d个。\n", total, len(existingTasks), len(suggestedTasks), len(pendingTasks)))
if len(existingTasks) > 0 {
sb.WriteString("\n已安排\n")
sb.WriteString(formatExistingList(existingTasks))
}
if len(suggestedTasks) > 0 {
sb.WriteString("\n已预排\n")
sb.WriteString(formatSuggestedList(suggestedTasks))
}
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 IsPendingTask(*task) {
statusLabel = "待安排"
} else if IsSuggestedTask(*task) {
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 IsPendingTask(*task) {
sb.WriteString(fmt.Sprintf("需要时段:%d个连续时段\n", task.Duration))
} else if IsSuggestedTask(*task) && task.Duration > 0 {
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()
}
// formatSuggestedList 格式化已预排任务列表。
// 格式如:[3]复习线代 — 已预排至 第2天第3-4节类别学习
func formatSuggestedList(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 — 已预排至 %s类别%s\n", t.StateID, t.Name, formatTaskSlotsBrief(t.Slots), t.Category))
}
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()
}