后端: 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` 方向
410 lines
15 KiB
Go
410 lines
15 KiB
Go
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(§ions, 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(§ions, 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(§ions, 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, "未分类"),
|
||
)
|
||
}
|