Files
smartmate/backend/newAgent/tools/schedule_read_slots_handlers.go
LoveLosita 1a5b2ecd73 Version: 0.9.51.dev.260428
后端:
1. schedule 读工具结果正式切到结构化 `schedule.read_result` 视图——`get_overview` / `query_range` / `query_available_slots` / `query_target_tasks` / `get_task_info` / `queue_status` 新增独立 handler,`ToolExecutionResult` 继续保留 `ObservationText` 给 LLM,但前端展示改走 `result_view` 的 collapsed / expanded 结构,统一输出 metrics / items / sections / machine_payload
2. `execution_result` 补齐第二批读工具参数本地化展示——扩展 `task_ids` / `task_item_ids` / `status` / `category` / `day_scope` / `week_filter` / `slot_types` / `include_pending` / `detail` / `dimensions` 等参数的排序权重、中文标签与展示格式,支持列表 / 布尔 / 周次 / 星期 / 节次等 `argument_view` 渲染
3. ToolRegistry 继续从内联注册收口到专属 handler——schedule 读工具从 `wrapLegacyToolHandler` 切到 `NewXxxToolHandler`,旧 `schedule` 子包里的 observation 生成逻辑暂时保留,当前切流点已落在 `newAgent/tools` 结构化适配层
4. 新增《工具结果结构化交接文档》,明确第二批 read 工具已迁移范围、`schedule.read_result` 协议、当前旧实现保留边界,以及下一轮建议迁移的 `schedule_analysis` 方向
2026-04-28 15:52:13 +08:00

410 lines
15 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 (
"encoding/json"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
)
// NewQueryAvailableSlotsToolHandler 为 query_available_slots 生成结构化读结果。
func NewQueryAvailableSlotsToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
observation := schedule.QueryAvailableSlots(state, args)
status, _ := resolveToolStatusAndSuccess(observation)
if status != ToolStatusDone {
return buildScheduleReadSimpleFailureResult("query_available_slots", args, state, observation)
}
payload, machinePayload := decodeAvailableSlotsPayload(observation)
items := make([]map[string]any, 0, len(payload.Slots))
for _, slot := range payload.Slots {
tags := []string{
fmt.Sprintf("第%d周", slot.Week),
formatScheduleWeekdayCN(slot.DayOfWeek),
formatSlotTypeLabelCN(slot.SlotType),
}
detailLines := []string{
fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd)),
fmt.Sprintf("跨度:%d 节", slot.SlotEnd-slot.SlotStart+1),
}
if strings.Contains(strings.ToLower(strings.TrimSpace(slot.SlotType)), "embed") {
if host := findScheduleHostTaskBySlotForRead(state, slot.Day, slot.SlotStart); host != nil {
detailLines = append(detailLines, fmt.Sprintf(
"宿主:[%d]%s%s",
host.StateID,
fallbackText(host.Name, "未命名任务"),
formatScheduleTaskStatusCN(*host),
))
}
}
items = append(items, buildScheduleReadItem(
formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd),
formatSlotTypeLabelCN(slot.SlotType),
tags,
detailLines,
map[string]any{
"day": slot.Day,
"week": slot.Week,
"day_of_week": slot.DayOfWeek,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
"slot_type": slot.SlotType,
},
))
}
metrics := []map[string]any{
buildScheduleReadMetric("候选时段", fmt.Sprintf("%d 个", payload.Count)),
buildScheduleReadMetric("纯空位", fmt.Sprintf("%d 个", payload.StrictCount)),
}
if payload.AllowEmbed {
metrics = append(metrics, buildScheduleReadMetric("可嵌入候选", fmt.Sprintf("%d 个", payload.EmbeddedCount)))
}
sections := []map[string]any{
buildScheduleReadKVSection("查询概况", []map[string]any{
buildScheduleReadKV("查询跨度", fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1))),
buildScheduleReadKV("日期范围", formatDayScopeLabelCN(payload.DayScope)),
buildScheduleReadKV("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
buildScheduleReadKV("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
buildScheduleReadKV("允许嵌入补位", formatBoolLabelCN(payload.AllowEmbed)),
buildScheduleReadKV("排除节次", formatScheduleSectionListCN(payload.ExcludeSections)),
}),
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("筛选条件", LegacyResultWithState("query_available_slots", args, state, observation).ArgumentView))
if len(items) > 0 {
sections = append(sections, buildScheduleReadItemsSection("候选时段", items))
} else {
sections = append(sections, buildScheduleReadCalloutSection(
"没有找到可用时段",
"当前筛选条件下没有命中的候选落点。",
"info",
[]string{"可以调整周次、星期、节次范围或是否允许嵌入补位。"},
))
}
title := fmt.Sprintf("找到 %d 个可用时段", payload.Count)
if payload.Count == 0 {
title = "未找到可用时段"
}
return buildScheduleReadResult(
"query_available_slots",
args,
state,
observation,
ToolStatusDone,
title,
buildAvailableSlotsSubtitle(payload),
metrics,
items,
sections,
machinePayload,
)
}
}
// NewQueryRangeToolHandler 为 query_range 生成结构化读结果。
func NewQueryRangeToolHandler() ToolHandler {
return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult {
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return buildScheduleReadSimpleFailureResult("query_range", args, state, "查询失败:缺少必填参数 day。")
}
if state == nil {
return buildScheduleReadSimpleFailureResult("query_range", args, nil, "查询失败:日程状态为空,无法读取时间范围。")
}
slotStart := schedule.ArgsIntPtr(args, "slot_start")
slotEnd := schedule.ArgsIntPtr(args, "slot_end")
observation := schedule.QueryRange(state, day, slotStart, slotEnd)
status, _ := resolveToolStatusAndSuccess(observation)
if status != ToolStatusDone {
return buildScheduleReadSimpleFailureResult("query_range", args, state, observation)
}
if slotStart == nil || slotEnd == nil {
return buildFullDayRangeReadResult(args, state, observation, day)
}
return buildSpecificRangeReadResult(args, state, observation, day, *slotStart, *slotEnd)
}
}
func buildFullDayRangeReadResult(args map[string]any, state *schedule.ScheduleState, observation string, day int) ToolExecutionResult {
totalOccupied := countScheduleDayOccupiedForRead(state, day)
taskOccupied := countScheduleDayTaskOccupiedForRead(state, day)
freeRanges := findScheduleFreeRangesOnDayForRead(state, day)
bandItems := make([]map[string]any, 0, 6)
for start := 1; start <= 11; start += 2 {
end := start + 1
occupants := listScheduleTasksInRangeForRead(state, day, start, end, true)
detailLines := make([]string, 0, len(occupants))
for _, occupant := range occupants {
detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task))
}
subtitle := "空闲"
tags := []string{"2 节"}
if len(occupants) > 0 {
subtitle = fmt.Sprintf("%d 个事项", len(occupants))
tags = append(tags, "已占用")
} else {
tags = append(tags, "空闲")
detailLines = append(detailLines, "这一段当前可直接安排任务。")
}
bandItems = append(bandItems, buildScheduleReadItem(
formatScheduleSlotRangeCN(start, end),
subtitle,
tags,
detailLines,
map[string]any{
"day": day,
"slot_start": start,
"slot_end": end,
},
))
}
freeItems := make([]map[string]any, 0, len(freeRanges))
for _, freeRange := range freeRanges {
freeItems = append(freeItems, buildScheduleReadItem(
formatScheduleSlotRangeCN(freeRange.SlotStart, freeRange.SlotEnd),
fmt.Sprintf("%d 节连续空闲", freeRange.SlotEnd-freeRange.SlotStart+1),
[]string{"连续空闲"},
[]string{fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(state, day, freeRange.SlotStart, freeRange.SlotEnd))},
map[string]any{
"day": day,
"slot_start": freeRange.SlotStart,
"slot_end": freeRange.SlotEnd,
},
))
}
taskEntries := listScheduleTasksOnDayForRead(state, day, false)
taskItems := make([]map[string]any, 0, len(taskEntries))
for _, entry := range taskEntries {
taskItems = append(taskItems, buildScheduleReadItem(
fmt.Sprintf("[%d]%s", entry.Task.StateID, fallbackText(entry.Task.Name, "未命名任务")),
formatScheduleTaskStatusCN(*entry.Task),
[]string{fallbackText(entry.Task.Category, "未分类")},
[]string{
fmt.Sprintf("时段:%s", formatScheduleSlotRangeCN(entry.SlotStart, entry.SlotEnd)),
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*entry.Task)),
},
map[string]any{
"task_id": entry.Task.StateID,
"slot_start": entry.SlotStart,
"slot_end": entry.SlotEnd,
"task_status": entry.Task.Status,
},
))
}
sections := []map[string]any{
buildScheduleReadKVSection("当日概况", []map[string]any{
buildScheduleReadKV("总占用", fmt.Sprintf("%d/12 节", totalOccupied)),
buildScheduleReadKV("任务占用", fmt.Sprintf("%d/12 节", taskOccupied)),
buildScheduleReadKV("连续空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
}),
buildScheduleReadItemsSection("时段分布", bandItems),
}
if len(freeItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("连续空闲区", freeItems))
}
if embeddableItems := buildEmbeddableItemsForDay(state, day); len(embeddableItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("可嵌入时段", embeddableItems))
}
if len(taskItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("当日任务", taskItems))
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("查询条件", LegacyResultWithState("query_range", args, state, observation).ArgumentView))
return buildScheduleReadResult(
"query_range",
args,
state,
observation,
ToolStatusDone,
fmt.Sprintf("%s全日概况", formatScheduleDayCN(state, day)),
fmt.Sprintf("已占用 %d/12 节,连续空闲 %d 段。", totalOccupied, len(freeRanges)),
[]map[string]any{
buildScheduleReadMetric("总占用", fmt.Sprintf("%d/12", totalOccupied)),
buildScheduleReadMetric("任务占用", fmt.Sprintf("%d/12", taskOccupied)),
buildScheduleReadMetric("空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
},
bandItems,
sections,
map[string]any{
"mode": "full_day",
"day": day,
"occupied_slots": totalOccupied,
"task_occupied_slots": taskOccupied,
"free_range_count": len(freeRanges),
},
)
}
func buildSpecificRangeReadResult(args map[string]any, state *schedule.ScheduleState, observation string, day int, slotStart int, slotEnd int) ToolExecutionResult {
total := slotEnd - slotStart + 1
freeCount := 0
slotItems := make([]map[string]any, 0, total)
for section := slotStart; section <= slotEnd; section++ {
occupants := listScheduleTasksInRangeForRead(state, day, section, section, true)
detailLines := make([]string, 0, len(occupants))
for _, occupant := range occupants {
detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task))
}
subtitle := "空闲"
tags := []string{"空闲"}
if len(occupants) > 0 {
subtitle = fmt.Sprintf("%d 个事项", len(occupants))
tags = []string{"已占用"}
} else {
freeCount++
detailLines = append(detailLines, "这一节当前为空。")
}
slotItems = append(slotItems, buildScheduleReadItem(
fmt.Sprintf("第%d节", section),
subtitle,
tags,
detailLines,
map[string]any{
"day": day,
"slot_start": section,
"slot_end": section,
},
))
}
seen := make(map[int]struct{})
rangeTaskItems := make([]map[string]any, 0)
for _, occupant := range listScheduleTasksInRangeForRead(state, day, slotStart, slotEnd, true) {
if _, exists := seen[occupant.Task.StateID]; exists {
continue
}
seen[occupant.Task.StateID] = struct{}{}
rangeTaskItems = append(rangeTaskItems, buildScheduleReadItem(
fmt.Sprintf("[%d]%s", occupant.Task.StateID, fallbackText(occupant.Task.Name, "未命名任务")),
formatScheduleTaskStatusCN(*occupant.Task),
[]string{fallbackText(occupant.Task.Category, "未分类")},
[]string{
fmt.Sprintf("覆盖范围:%s", formatScheduleDaySlotCN(state, day, occupant.SlotStart, occupant.SlotEnd)),
fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*occupant.Task)),
},
map[string]any{
"task_id": occupant.Task.StateID,
"slot_start": occupant.SlotStart,
"slot_end": occupant.SlotEnd,
"task_status": occupant.Task.Status,
},
))
}
sections := []map[string]any{
buildScheduleReadKVSection("范围概况", []map[string]any{
buildScheduleReadKV("查询范围", formatScheduleSlotRangeCN(slotStart, slotEnd)),
buildScheduleReadKV("总节数", fmt.Sprintf("%d 节", total)),
buildScheduleReadKV("空闲节数", fmt.Sprintf("%d 节", freeCount)),
buildScheduleReadKV("占用节数", fmt.Sprintf("%d 节", total-freeCount)),
}),
buildScheduleReadItemsSection("逐节情况", slotItems),
}
if len(rangeTaskItems) > 0 {
sections = append(sections, buildScheduleReadItemsSection("范围内事项", rangeTaskItems))
}
appendSectionIfPresent(&sections, buildScheduleReadArgsSection("查询条件", LegacyResultWithState("query_range", args, state, observation).ArgumentView))
return buildScheduleReadResult(
"query_range",
args,
state,
observation,
ToolStatusDone,
fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatScheduleSlotRangeCN(slotStart, slotEnd)),
fmt.Sprintf("共 %d 节,空闲 %d 节,占用 %d 节。", total, freeCount, total-freeCount),
[]map[string]any{
buildScheduleReadMetric("总节数", fmt.Sprintf("%d 节", total)),
buildScheduleReadMetric("空闲", fmt.Sprintf("%d 节", freeCount)),
buildScheduleReadMetric("事项", fmt.Sprintf("%d 个", len(rangeTaskItems))),
},
slotItems,
sections,
map[string]any{
"mode": "specific_range",
"day": day,
"slot_start": slotStart,
"slot_end": slotEnd,
"free_count": freeCount,
"occupied_count": total - freeCount,
},
)
}
func decodeAvailableSlotsPayload(observation string) (scheduleReadAvailableSlotsPayload, map[string]any) {
var payload scheduleReadAvailableSlotsPayload
_ = json.Unmarshal([]byte(strings.TrimSpace(observation)), &payload)
raw, _ := parseObservationJSON(strings.TrimSpace(observation))
return payload, raw
}
func buildAvailableSlotsSubtitle(payload scheduleReadAvailableSlotsPayload) string {
parts := []string{
fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1)),
formatDayScopeLabelCN(payload.DayScope),
buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter),
}
if len(payload.DayOfWeek) > 0 {
parts = append(parts, formatWeekdayListCN(payload.DayOfWeek))
}
if payload.AllowEmbed {
parts = append(parts, "允许补充可嵌入候选")
} else {
parts = append(parts, "仅查看纯空位")
}
return strings.Join(parts, "")
}
func buildEmbeddableItemsForDay(state *schedule.ScheduleState, day int) []map[string]any {
if state == nil {
return nil
}
items := make([]map[string]any, 0)
for i := range state.Tasks {
task := state.Tasks[i]
if !task.CanEmbed || task.EmbeddedBy != nil || task.EmbedHost != nil {
continue
}
for _, slot := range task.Slots {
if slot.Day != day {
continue
}
items = append(items, buildScheduleReadItem(
formatScheduleSlotRangeCN(slot.SlotStart, slot.SlotEnd),
fmt.Sprintf("可嵌入到 [%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")),
[]string{fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(task)},
[]string{
fmt.Sprintf("宿主时段:%s", formatScheduleDaySlotCN(state, day, slot.SlotStart, slot.SlotEnd)),
"该时段允许放入更短的嵌入任务。",
},
map[string]any{
"host_task_id": task.StateID,
"day": day,
"slot_start": slot.SlotStart,
"slot_end": slot.SlotEnd,
},
))
}
}
return items
}
func buildRangeOccupantLine(task schedule.ScheduleTask) string {
return fmt.Sprintf(
"[%d]%s%s%s",
task.StateID,
fallbackText(task.Name, "未命名任务"),
formatScheduleTaskStatusCN(task),
fallbackText(task.Category, "未分类"),
)
}