后端: 1.收口阶段 6 agent 结构迁移,将 newAgent 内核与 agentsvc 编排层迁入 services/agent - 切换 Agent 启动装配与 HTTP handler 直连 agent sv,移除旧 service agent bridge - 补齐 Agent 对 memory、task、task-class、schedule 的 RPC 适配与契约字段 - 扩展 schedule、task、task-class RPC/contract 支撑 Agent 查询、写入与 provider 切流 - 更新迁移文档、README 与相关注释,明确 agent 当前切流点和剩余 memory 迁移面
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package schedule_read
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||
)
|
||
|
||
// BuildAvailableSlotsView 构造 query_available_slots 的纯展示视图。
|
||
func BuildAvailableSlotsView(input AvailableSlotsViewInput) ReadResultView {
|
||
payload, machinePayload, ok := DecodeAvailableSlotsPayload(input.Observation)
|
||
if !ok || !payload.Success {
|
||
return BuildFailureView(BuildFailureViewInput{
|
||
ToolName: "query_available_slots",
|
||
Observation: input.Observation,
|
||
ArgFields: input.ArgFields,
|
||
})
|
||
}
|
||
|
||
items := make([]ItemView, 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(input.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(input.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, BuildItem(
|
||
formatScheduleDaySlotCN(input.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 := []MetricField{
|
||
BuildMetric("候选时段", fmt.Sprintf("%d 个", payload.Count)),
|
||
BuildMetric("纯空位", fmt.Sprintf("%d 个", payload.StrictCount)),
|
||
}
|
||
if payload.AllowEmbed {
|
||
metrics = append(metrics, BuildMetric("可嵌入候选", fmt.Sprintf("%d 个", payload.EmbeddedCount)))
|
||
}
|
||
|
||
sections := []map[string]any{
|
||
BuildKVSection("查询概况", []KVField{
|
||
BuildKVField("查询跨度", fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1))),
|
||
BuildKVField("日期范围", formatDayScopeLabelCN(payload.DayScope)),
|
||
BuildKVField("星期过滤", formatWeekdayListCN(payload.DayOfWeek)),
|
||
BuildKVField("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)),
|
||
BuildKVField("允许嵌入补位", formatBoolLabelCN(payload.AllowEmbed)),
|
||
BuildKVField("排除节次", formatScheduleSectionListCN(payload.ExcludeSections)),
|
||
}),
|
||
}
|
||
appendSectionIfPresent(§ions, BuildArgsSection("筛选条件", input.ArgFields))
|
||
if len(items) > 0 {
|
||
sections = append(sections, BuildItemsSection("候选时段", items))
|
||
} else {
|
||
sections = append(sections, BuildCalloutSection(
|
||
"没有找到可用时段",
|
||
"当前筛选条件下没有命中的候选落点。",
|
||
"info",
|
||
[]string{"可以调整周次、星期、节次范围,或修改是否允许嵌入补位。"},
|
||
))
|
||
}
|
||
|
||
title := fmt.Sprintf("找到 %d 个可用时段", payload.Count)
|
||
if payload.Count == 0 {
|
||
title = "未找到可用时段"
|
||
}
|
||
return BuildResultView(BuildResultViewInput{
|
||
Status: StatusDone,
|
||
Title: title,
|
||
Subtitle: buildAvailableSlotsSubtitle(payload),
|
||
Metrics: metrics,
|
||
Items: items,
|
||
Sections: sections,
|
||
Observation: input.Observation,
|
||
MachinePayload: machinePayload,
|
||
})
|
||
}
|
||
|
||
// BuildRangeView 根据是否传入 slot_start / slot_end 选择整天或指定范围视图。
|
||
func BuildRangeView(input RangeViewInput) ReadResultView {
|
||
if input.State == nil {
|
||
return BuildFailureView(BuildFailureViewInput{
|
||
ToolName: "query_range",
|
||
Observation: input.Observation,
|
||
ArgFields: input.ArgFields,
|
||
})
|
||
}
|
||
if input.SlotStart == nil || input.SlotEnd == nil {
|
||
return BuildRangeFullDayView(RangeFullDayViewInput{
|
||
State: input.State,
|
||
Observation: input.Observation,
|
||
Day: input.Day,
|
||
ArgFields: input.ArgFields,
|
||
})
|
||
}
|
||
return BuildRangeSpecificView(RangeSpecificViewInput{
|
||
State: input.State,
|
||
Observation: input.Observation,
|
||
Day: input.Day,
|
||
SlotStart: *input.SlotStart,
|
||
SlotEnd: *input.SlotEnd,
|
||
ArgFields: input.ArgFields,
|
||
})
|
||
}
|
||
|
||
// BuildRangeFullDayView 构造 query_range 整天模式视图。
|
||
func BuildRangeFullDayView(input RangeFullDayViewInput) ReadResultView {
|
||
if input.State == nil {
|
||
return BuildFailureView(BuildFailureViewInput{
|
||
ToolName: "query_range",
|
||
Observation: input.Observation,
|
||
ArgFields: input.ArgFields,
|
||
})
|
||
}
|
||
|
||
totalOccupied := countScheduleDayOccupiedForRead(input.State, input.Day)
|
||
taskOccupied := countScheduleDayTaskOccupiedForRead(input.State, input.Day)
|
||
freeRanges := findScheduleFreeRangesOnDayForRead(input.State, input.Day)
|
||
|
||
bandItems := make([]ItemView, 0, 6)
|
||
for start := 1; start <= 11; start += 2 {
|
||
end := start + 1
|
||
occupants := listScheduleTasksInRangeForRead(input.State, input.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, BuildItem(
|
||
formatScheduleSlotRangeCN(start, end),
|
||
subtitle,
|
||
tags,
|
||
detailLines,
|
||
map[string]any{
|
||
"day": input.Day,
|
||
"slot_start": start,
|
||
"slot_end": end,
|
||
},
|
||
))
|
||
}
|
||
|
||
freeItems := make([]ItemView, 0, len(freeRanges))
|
||
for _, freeRange := range freeRanges {
|
||
freeItems = append(freeItems, BuildItem(
|
||
formatScheduleSlotRangeCN(freeRange.SlotStart, freeRange.SlotEnd),
|
||
fmt.Sprintf("%d 节连续空闲", freeRange.SlotEnd-freeRange.SlotStart+1),
|
||
[]string{"连续空闲"},
|
||
[]string{fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(input.State, input.Day, freeRange.SlotStart, freeRange.SlotEnd))},
|
||
map[string]any{
|
||
"day": input.Day,
|
||
"slot_start": freeRange.SlotStart,
|
||
"slot_end": freeRange.SlotEnd,
|
||
},
|
||
))
|
||
}
|
||
|
||
taskEntries := listScheduleTasksOnDayForRead(input.State, input.Day, false)
|
||
taskItems := make([]ItemView, 0, len(taskEntries))
|
||
for _, entry := range taskEntries {
|
||
taskItems = append(taskItems, BuildItem(
|
||
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{
|
||
BuildKVSection("当日概况", []KVField{
|
||
BuildKVField("总占用", fmt.Sprintf("%d/12 节", totalOccupied)),
|
||
BuildKVField("任务占用", fmt.Sprintf("%d/12 节", taskOccupied)),
|
||
BuildKVField("连续空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
|
||
}),
|
||
BuildItemsSection("时段分布", bandItems),
|
||
}
|
||
if len(freeItems) > 0 {
|
||
sections = append(sections, BuildItemsSection("连续空闲区", freeItems))
|
||
}
|
||
if embeddableItems := buildEmbeddableItemsForDay(input.State, input.Day); len(embeddableItems) > 0 {
|
||
sections = append(sections, BuildItemsSection("可嵌入时段", embeddableItems))
|
||
}
|
||
if len(taskItems) > 0 {
|
||
sections = append(sections, BuildItemsSection("当日任务", taskItems))
|
||
}
|
||
appendSectionIfPresent(§ions, BuildArgsSection("查询条件", input.ArgFields))
|
||
|
||
return BuildResultView(BuildResultViewInput{
|
||
Status: StatusDone,
|
||
Title: fmt.Sprintf("%s全日概况", formatScheduleDayCN(input.State, input.Day)),
|
||
Subtitle: fmt.Sprintf("已占用 %d/12 节,连续空闲 %d 段。", totalOccupied, len(freeRanges)),
|
||
Metrics: []MetricField{
|
||
BuildMetric("总占用", fmt.Sprintf("%d/12", totalOccupied)),
|
||
BuildMetric("任务占用", fmt.Sprintf("%d/12", taskOccupied)),
|
||
BuildMetric("空闲段", fmt.Sprintf("%d 段", len(freeRanges))),
|
||
},
|
||
Items: bandItems,
|
||
Sections: sections,
|
||
Observation: input.Observation,
|
||
MachinePayload: map[string]any{
|
||
"mode": "full_day",
|
||
"day": input.Day,
|
||
"occupied_slots": totalOccupied,
|
||
"task_occupied_slots": taskOccupied,
|
||
"free_range_count": len(freeRanges),
|
||
},
|
||
})
|
||
}
|
||
|
||
// BuildRangeSpecificView 构造 query_range 指定范围模式视图。
|
||
func BuildRangeSpecificView(input RangeSpecificViewInput) ReadResultView {
|
||
if input.State == nil {
|
||
return BuildFailureView(BuildFailureViewInput{
|
||
ToolName: "query_range",
|
||
Observation: input.Observation,
|
||
ArgFields: input.ArgFields,
|
||
})
|
||
}
|
||
|
||
total := input.SlotEnd - input.SlotStart + 1
|
||
freeCount := 0
|
||
slotItems := make([]ItemView, 0, total)
|
||
for section := input.SlotStart; section <= input.SlotEnd; section++ {
|
||
occupants := listScheduleTasksInRangeForRead(input.State, input.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, BuildItem(
|
||
fmt.Sprintf("第%d节", section),
|
||
subtitle,
|
||
tags,
|
||
detailLines,
|
||
map[string]any{
|
||
"day": input.Day,
|
||
"slot_start": section,
|
||
"slot_end": section,
|
||
},
|
||
))
|
||
}
|
||
|
||
seen := make(map[int]struct{})
|
||
rangeTaskItems := make([]ItemView, 0)
|
||
for _, occupant := range listScheduleTasksInRangeForRead(input.State, input.Day, input.SlotStart, input.SlotEnd, true) {
|
||
if _, exists := seen[occupant.Task.StateID]; exists {
|
||
continue
|
||
}
|
||
seen[occupant.Task.StateID] = struct{}{}
|
||
rangeTaskItems = append(rangeTaskItems, BuildItem(
|
||
fmt.Sprintf("[%d]%s", occupant.Task.StateID, fallbackText(occupant.Task.Name, "未命名任务")),
|
||
formatScheduleTaskStatusCN(*occupant.Task),
|
||
[]string{fallbackText(occupant.Task.Category, "未分类")},
|
||
[]string{
|
||
fmt.Sprintf("覆盖范围:%s", formatScheduleDaySlotCN(input.State, input.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{
|
||
BuildKVSection("范围概况", []KVField{
|
||
BuildKVField("查询范围", formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)),
|
||
BuildKVField("总节数", fmt.Sprintf("%d 节", total)),
|
||
BuildKVField("空闲节数", fmt.Sprintf("%d 节", freeCount)),
|
||
BuildKVField("占用节数", fmt.Sprintf("%d 节", total-freeCount)),
|
||
}),
|
||
BuildItemsSection("逐节情况", slotItems),
|
||
}
|
||
if len(rangeTaskItems) > 0 {
|
||
sections = append(sections, BuildItemsSection("范围内事项", rangeTaskItems))
|
||
}
|
||
appendSectionIfPresent(§ions, BuildArgsSection("查询条件", input.ArgFields))
|
||
|
||
return BuildResultView(BuildResultViewInput{
|
||
Status: StatusDone,
|
||
Title: fmt.Sprintf("%s %s", formatScheduleDayCN(input.State, input.Day), formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)),
|
||
Subtitle: fmt.Sprintf("共 %d 节,空闲 %d 节,占用 %d 节。", total, freeCount, total-freeCount),
|
||
Metrics: []MetricField{
|
||
BuildMetric("总节数", fmt.Sprintf("%d 节", total)),
|
||
BuildMetric("空闲", fmt.Sprintf("%d 节", freeCount)),
|
||
BuildMetric("事项", fmt.Sprintf("%d 个", len(rangeTaskItems))),
|
||
},
|
||
Items: slotItems,
|
||
Sections: sections,
|
||
Observation: input.Observation,
|
||
MachinePayload: map[string]any{
|
||
"mode": "specific_range",
|
||
"day": input.Day,
|
||
"slot_start": input.SlotStart,
|
||
"slot_end": input.SlotEnd,
|
||
"free_count": freeCount,
|
||
"occupied_count": total - freeCount,
|
||
},
|
||
})
|
||
}
|
||
|
||
// DecodeAvailableSlotsPayload 解析 query_available_slots 的 JSON observation。
|
||
func DecodeAvailableSlotsPayload(observation string) (AvailableSlotsPayload, map[string]any, bool) {
|
||
var payload AvailableSlotsPayload
|
||
trimmed := strings.TrimSpace(observation)
|
||
if trimmed == "" {
|
||
return payload, nil, false
|
||
}
|
||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||
return payload, nil, false
|
||
}
|
||
raw, ok := parseObservationJSON(trimmed)
|
||
return payload, raw, ok
|
||
}
|
||
|
||
func buildAvailableSlotsSubtitle(payload AvailableSlotsPayload) 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) []ItemView {
|
||
if state == nil {
|
||
return nil
|
||
}
|
||
items := make([]ItemView, 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, BuildItem(
|
||
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, "未分类"),
|
||
)
|
||
}
|