后端: 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` 方向
625 lines
15 KiB
Go
625 lines
15 KiB
Go
package newagenttools
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
|
||
)
|
||
|
||
// buildScheduleReadResult 统一封装第二批 read 工具的结构化返回。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责保留原始 ObservationText,确保 LLM 看到的 observation 不变。
|
||
// 2. 负责把各工具已经算好的中文标题、指标、分区组装成统一的 ResultView。
|
||
// 3. 不负责具体业务解释,具体内容由各 read handler 先计算后传入。
|
||
func buildScheduleReadResult(
|
||
toolName string,
|
||
args map[string]any,
|
||
state *schedule.ScheduleState,
|
||
observation string,
|
||
status string,
|
||
title string,
|
||
subtitle string,
|
||
metrics []map[string]any,
|
||
items []map[string]any,
|
||
sections []map[string]any,
|
||
machinePayload map[string]any,
|
||
) ToolExecutionResult {
|
||
result := LegacyResultWithState(toolName, args, state, observation)
|
||
|
||
normalizedStatus := normalizeToolStatus(status)
|
||
if normalizedStatus == "" {
|
||
normalizedStatus = ToolStatusDone
|
||
}
|
||
if metrics == nil {
|
||
metrics = make([]map[string]any, 0)
|
||
}
|
||
if items == nil {
|
||
items = make([]map[string]any, 0)
|
||
}
|
||
if sections == nil {
|
||
sections = make([]map[string]any, 0)
|
||
}
|
||
|
||
expanded := map[string]any{
|
||
"items": items,
|
||
"sections": sections,
|
||
"raw_text": strings.TrimSpace(observation),
|
||
}
|
||
if len(machinePayload) > 0 {
|
||
expanded["machine_payload"] = cloneAnyMap(machinePayload)
|
||
}
|
||
|
||
result.Status = normalizedStatus
|
||
result.Success = normalizedStatus == ToolStatusDone
|
||
result.Summary = strings.TrimSpace(title)
|
||
result.ResultView = &ToolDisplayView{
|
||
ViewType: scheduleReadResultViewType,
|
||
Version: 1,
|
||
Collapsed: map[string]any{
|
||
"title": strings.TrimSpace(title),
|
||
"subtitle": strings.TrimSpace(subtitle),
|
||
"status": normalizedStatus,
|
||
"status_label": resolveToolStatusLabelCN(normalizedStatus),
|
||
"metrics": metrics,
|
||
},
|
||
Expanded: expanded,
|
||
}
|
||
if !result.Success {
|
||
errorCode, errorMessage := extractToolErrorInfo(observation, normalizedStatus)
|
||
if strings.TrimSpace(result.ErrorCode) == "" {
|
||
result.ErrorCode = errorCode
|
||
}
|
||
if strings.TrimSpace(result.ErrorMessage) == "" {
|
||
result.ErrorMessage = errorMessage
|
||
}
|
||
}
|
||
return EnsureToolResultDefaults(result, args)
|
||
}
|
||
|
||
func buildScheduleReadMetric(label string, value string) map[string]any {
|
||
return map[string]any{
|
||
"label": strings.TrimSpace(label),
|
||
"value": strings.TrimSpace(value),
|
||
}
|
||
}
|
||
|
||
func buildScheduleReadItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) map[string]any {
|
||
item := map[string]any{
|
||
"title": strings.TrimSpace(title),
|
||
"subtitle": strings.TrimSpace(subtitle),
|
||
"tags": normalizeStringSlice(tags),
|
||
"detail_lines": normalizeStringSlice(detailLines),
|
||
}
|
||
if len(meta) > 0 {
|
||
item["meta"] = cloneAnyMap(meta)
|
||
}
|
||
return item
|
||
}
|
||
|
||
func buildScheduleReadKV(label string, value string) map[string]any {
|
||
return map[string]any{
|
||
"label": strings.TrimSpace(label),
|
||
"value": strings.TrimSpace(value),
|
||
}
|
||
}
|
||
|
||
func buildScheduleReadItemsSection(title string, items []map[string]any) map[string]any {
|
||
if items == nil {
|
||
items = make([]map[string]any, 0)
|
||
}
|
||
return map[string]any{
|
||
"type": "items",
|
||
"title": strings.TrimSpace(title),
|
||
"items": items,
|
||
}
|
||
}
|
||
|
||
func buildScheduleReadKVSection(title string, fields []map[string]any) map[string]any {
|
||
if fields == nil {
|
||
fields = make([]map[string]any, 0)
|
||
}
|
||
return map[string]any{
|
||
"type": "kv",
|
||
"title": strings.TrimSpace(title),
|
||
"fields": fields,
|
||
}
|
||
}
|
||
|
||
func buildScheduleReadCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any {
|
||
return map[string]any{
|
||
"type": "callout",
|
||
"title": strings.TrimSpace(title),
|
||
"subtitle": strings.TrimSpace(subtitle),
|
||
"tone": strings.TrimSpace(tone),
|
||
"detail_lines": normalizeStringSlice(detailLines),
|
||
}
|
||
}
|
||
|
||
func buildScheduleReadArgsSection(title string, view *ToolArgumentView) map[string]any {
|
||
if view == nil || view.Expanded == nil {
|
||
return nil
|
||
}
|
||
rawFields, ok := view.Expanded["fields"].([]map[string]any)
|
||
if ok {
|
||
fields := make([]map[string]any, 0, len(rawFields))
|
||
for _, raw := range rawFields {
|
||
label, _ := raw["label"].(string)
|
||
display, _ := raw["display"].(string)
|
||
if strings.TrimSpace(label) == "" || strings.TrimSpace(display) == "" {
|
||
continue
|
||
}
|
||
fields = append(fields, buildScheduleReadKV(label, display))
|
||
}
|
||
if len(fields) > 0 {
|
||
return buildScheduleReadKVSection(title, fields)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
rawAny, ok := view.Expanded["fields"].([]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
fields := make([]map[string]any, 0, len(rawAny))
|
||
for _, current := range rawAny {
|
||
row, ok := current.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
label, _ := row["label"].(string)
|
||
display, _ := row["display"].(string)
|
||
if strings.TrimSpace(label) == "" || strings.TrimSpace(display) == "" {
|
||
continue
|
||
}
|
||
fields = append(fields, buildScheduleReadKV(label, display))
|
||
}
|
||
if len(fields) == 0 {
|
||
return nil
|
||
}
|
||
return buildScheduleReadKVSection(title, fields)
|
||
}
|
||
|
||
func buildReadFailureSections(argView *ToolArgumentView, observation string) []map[string]any {
|
||
message := trimFailureText(observation, "读取结果失败,请检查参数后重试。")
|
||
sections := []map[string]any{
|
||
buildScheduleReadCalloutSection("执行失败", message, "danger", []string{message}),
|
||
}
|
||
appendSectionIfPresent(§ions, buildScheduleReadArgsSection("查询条件", argView))
|
||
return sections
|
||
}
|
||
|
||
func buildScheduleReadSimpleFailureResult(toolName string, args map[string]any, state *schedule.ScheduleState, observation string) ToolExecutionResult {
|
||
legacy := LegacyResultWithState(toolName, args, state, observation)
|
||
return buildScheduleReadResult(
|
||
toolName,
|
||
args,
|
||
state,
|
||
observation,
|
||
ToolStatusFailed,
|
||
fmt.Sprintf("%s失败", resolveToolLabelCN(toolName)),
|
||
trimFailureText(observation, "请检查筛选条件后重试。"),
|
||
nil,
|
||
nil,
|
||
buildReadFailureSections(legacy.ArgumentView, observation),
|
||
nil,
|
||
)
|
||
}
|
||
|
||
func appendSectionIfPresent(target *[]map[string]any, section map[string]any) {
|
||
if section == nil {
|
||
return
|
||
}
|
||
*target = append(*target, section)
|
||
}
|
||
|
||
func normalizeStringSlice(values []string) []string {
|
||
if len(values) == 0 {
|
||
return make([]string, 0)
|
||
}
|
||
out := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
text := strings.TrimSpace(value)
|
||
if text == "" {
|
||
continue
|
||
}
|
||
out = append(out, text)
|
||
}
|
||
if len(out) == 0 {
|
||
return make([]string, 0)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func trimFailureText(observation string, fallback string) string {
|
||
status, _ := resolveToolStatusAndSuccess(observation)
|
||
_, message := extractToolErrorInfo(observation, status)
|
||
if strings.TrimSpace(message) != "" {
|
||
return strings.TrimSpace(message)
|
||
}
|
||
if strings.TrimSpace(observation) != "" {
|
||
return strings.TrimSpace(observation)
|
||
}
|
||
return strings.TrimSpace(fallback)
|
||
}
|
||
|
||
func formatScheduleDayCN(state *schedule.ScheduleState, day int) string {
|
||
if day <= 0 {
|
||
return "未知日期"
|
||
}
|
||
if state != nil {
|
||
if week, dayOfWeek, ok := state.DayToWeekDay(day); ok {
|
||
return fmt.Sprintf("第%d天(第%d周 %s)", day, week, formatScheduleWeekdayCN(dayOfWeek))
|
||
}
|
||
}
|
||
return fmt.Sprintf("第%d天", day)
|
||
}
|
||
|
||
func formatScheduleWeekdayCN(dayOfWeek int) string {
|
||
switch dayOfWeek {
|
||
case 1:
|
||
return "周一"
|
||
case 2:
|
||
return "周二"
|
||
case 3:
|
||
return "周三"
|
||
case 4:
|
||
return "周四"
|
||
case 5:
|
||
return "周五"
|
||
case 6:
|
||
return "周六"
|
||
case 7:
|
||
return "周日"
|
||
default:
|
||
return fmt.Sprintf("周%d", dayOfWeek)
|
||
}
|
||
}
|
||
|
||
func formatScheduleSlotRangeCN(start int, end int) string {
|
||
if start <= 0 {
|
||
return "未知节次"
|
||
}
|
||
if end <= 0 || end < start {
|
||
end = start
|
||
}
|
||
return fmt.Sprintf("第%d-%d节", start, end)
|
||
}
|
||
|
||
func formatScheduleDaySlotCN(state *schedule.ScheduleState, day int, start int, end int) string {
|
||
return fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatScheduleSlotRangeCN(start, end))
|
||
}
|
||
|
||
func formatScheduleWeekListCN(weeks []int) string {
|
||
if len(weeks) == 0 {
|
||
return "不限周次"
|
||
}
|
||
parts := make([]string, 0, len(weeks))
|
||
for _, week := range weeks {
|
||
if week <= 0 {
|
||
continue
|
||
}
|
||
parts = append(parts, fmt.Sprintf("第%d周", week))
|
||
}
|
||
if len(parts) == 0 {
|
||
return "不限周次"
|
||
}
|
||
return strings.Join(parts, "、")
|
||
}
|
||
|
||
func formatScheduleSectionListCN(sections []int) string {
|
||
if len(sections) == 0 {
|
||
return "无"
|
||
}
|
||
parts := make([]string, 0, len(sections))
|
||
for _, section := range sections {
|
||
if section <= 0 {
|
||
continue
|
||
}
|
||
parts = append(parts, fmt.Sprintf("第%d节", section))
|
||
}
|
||
if len(parts) == 0 {
|
||
return "无"
|
||
}
|
||
return strings.Join(parts, "、")
|
||
}
|
||
|
||
func formatScheduleTaskStatusCN(task schedule.ScheduleTask) string {
|
||
switch {
|
||
case schedule.IsPendingTask(task):
|
||
return "待安排"
|
||
case schedule.IsSuggestedTask(task):
|
||
return "已预排"
|
||
default:
|
||
if task.Locked {
|
||
return "已安排(固定)"
|
||
}
|
||
return "已安排"
|
||
}
|
||
}
|
||
|
||
func formatTargetTaskStatusCN(status string) string {
|
||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||
case "existing":
|
||
return "已安排"
|
||
case "suggested":
|
||
return "已预排"
|
||
case "pending":
|
||
return "待安排"
|
||
default:
|
||
return fallbackText(status, "未标注")
|
||
}
|
||
}
|
||
|
||
func formatTargetPoolStatusCN(status string) string {
|
||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||
case "all":
|
||
return "全部任务"
|
||
case "existing":
|
||
return "已安排任务"
|
||
case "suggested":
|
||
return "已预排任务"
|
||
case "pending":
|
||
return "待安排任务"
|
||
default:
|
||
return fallbackText(status, "任务池")
|
||
}
|
||
}
|
||
|
||
func formatSlotTypeLabelCN(slotType string) string {
|
||
switch strings.ToLower(strings.TrimSpace(slotType)) {
|
||
case "", "empty", "strict":
|
||
return "纯空位"
|
||
case "embedded_candidate", "embedded", "embed":
|
||
return "可嵌入候选"
|
||
default:
|
||
return strings.TrimSpace(slotType)
|
||
}
|
||
}
|
||
|
||
func formatDayScopeLabelCN(scope string) string {
|
||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||
case "workday":
|
||
return "工作日"
|
||
case "weekend":
|
||
return "周末"
|
||
default:
|
||
return "全部日期"
|
||
}
|
||
}
|
||
|
||
func buildWeekRangeLabelCN(weekFrom int, weekTo int, weekFilter []int) string {
|
||
if len(weekFilter) > 0 {
|
||
return formatScheduleWeekListCN(weekFilter)
|
||
}
|
||
if weekFrom > 0 && weekTo > 0 {
|
||
if weekFrom == weekTo {
|
||
return fmt.Sprintf("第%d周", weekFrom)
|
||
}
|
||
return fmt.Sprintf("第%d-%d周", weekFrom, weekTo)
|
||
}
|
||
return "全部周次"
|
||
}
|
||
|
||
func formatBoolLabelCN(value bool) string {
|
||
if value {
|
||
return "是"
|
||
}
|
||
return "否"
|
||
}
|
||
|
||
func formatWeekdayListCN(days []int) string {
|
||
if len(days) == 0 {
|
||
return "不限星期"
|
||
}
|
||
parts := make([]string, 0, len(days))
|
||
for _, day := range days {
|
||
parts = append(parts, formatScheduleWeekdayCN(day))
|
||
}
|
||
return strings.Join(parts, "、")
|
||
}
|
||
|
||
func formatScheduleTaskSourceCN(task schedule.ScheduleTask) string {
|
||
switch strings.TrimSpace(task.Source) {
|
||
case "event":
|
||
if isCourseScheduleTaskForRead(task) {
|
||
return "课程表"
|
||
}
|
||
return "日程事件"
|
||
case "task_item":
|
||
return "任务项"
|
||
default:
|
||
return fallbackText(task.Source, "未知来源")
|
||
}
|
||
}
|
||
|
||
func formatScheduleTaskSlotsBriefCN(state *schedule.ScheduleState, slots []schedule.TaskSlot) string {
|
||
if len(slots) == 0 {
|
||
return "尚未落位"
|
||
}
|
||
parts := make([]string, 0, len(slots))
|
||
for _, slot := range cloneAndSortTaskSlots(slots) {
|
||
parts = append(parts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd))
|
||
}
|
||
return strings.Join(parts, ";")
|
||
}
|
||
|
||
func countScheduleDayOccupiedForRead(state *schedule.ScheduleState, day int) int {
|
||
if state == nil {
|
||
return 0
|
||
}
|
||
occupied := 0
|
||
for i := range state.Tasks {
|
||
task := state.Tasks[i]
|
||
if task.EmbedHost != nil {
|
||
continue
|
||
}
|
||
for _, slot := range task.Slots {
|
||
if slot.Day == day {
|
||
occupied += slot.SlotEnd - slot.SlotStart + 1
|
||
}
|
||
}
|
||
}
|
||
return occupied
|
||
}
|
||
|
||
func countScheduleDayTaskOccupiedForRead(state *schedule.ScheduleState, day int) int {
|
||
if state == nil {
|
||
return 0
|
||
}
|
||
occupied := 0
|
||
for i := range state.Tasks {
|
||
task := state.Tasks[i]
|
||
if isCourseScheduleTaskForRead(task) || task.EmbedHost != nil {
|
||
continue
|
||
}
|
||
for _, slot := range task.Slots {
|
||
if slot.Day == day {
|
||
occupied += slot.SlotEnd - slot.SlotStart + 1
|
||
}
|
||
}
|
||
}
|
||
return occupied
|
||
}
|
||
|
||
func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, includeCourse bool) []scheduleReadTaskOnDay {
|
||
if state == nil {
|
||
return nil
|
||
}
|
||
items := make([]scheduleReadTaskOnDay, 0)
|
||
for i := range state.Tasks {
|
||
task := &state.Tasks[i]
|
||
if !includeCourse && isCourseScheduleTaskForRead(*task) {
|
||
continue
|
||
}
|
||
for _, slot := range task.Slots {
|
||
if slot.Day != day {
|
||
continue
|
||
}
|
||
items = append(items, scheduleReadTaskOnDay{
|
||
Task: task,
|
||
SlotStart: slot.SlotStart,
|
||
SlotEnd: slot.SlotEnd,
|
||
})
|
||
}
|
||
}
|
||
sort.Slice(items, func(i, j int) bool {
|
||
if items[i].SlotStart != items[j].SlotStart {
|
||
return items[i].SlotStart < items[j].SlotStart
|
||
}
|
||
if items[i].SlotEnd != items[j].SlotEnd {
|
||
return items[i].SlotEnd < items[j].SlotEnd
|
||
}
|
||
return items[i].Task.StateID < items[j].Task.StateID
|
||
})
|
||
return items
|
||
}
|
||
|
||
func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, start int, end int, includeCourse bool) []scheduleReadTaskOnDay {
|
||
items := listScheduleTasksOnDayForRead(state, day, includeCourse)
|
||
filtered := make([]scheduleReadTaskOnDay, 0, len(items))
|
||
for _, item := range items {
|
||
if item.SlotStart <= end && item.SlotEnd >= start {
|
||
filtered = append(filtered, item)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) []scheduleReadFreeRange {
|
||
if state == nil {
|
||
return nil
|
||
}
|
||
occupied := make([]bool, 13)
|
||
for i := range state.Tasks {
|
||
task := state.Tasks[i]
|
||
if task.EmbedHost != nil {
|
||
continue
|
||
}
|
||
for _, slot := range task.Slots {
|
||
if slot.Day != day {
|
||
continue
|
||
}
|
||
for section := slot.SlotStart; section <= slot.SlotEnd; section++ {
|
||
if section >= 1 && section <= 12 {
|
||
occupied[section] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ranges := make([]scheduleReadFreeRange, 0)
|
||
start := 0
|
||
for section := 1; section <= 12; section++ {
|
||
if !occupied[section] {
|
||
if start == 0 {
|
||
start = section
|
||
}
|
||
continue
|
||
}
|
||
if start > 0 {
|
||
ranges = append(ranges, scheduleReadFreeRange{Day: day, SlotStart: start, SlotEnd: section - 1})
|
||
start = 0
|
||
}
|
||
}
|
||
if start > 0 {
|
||
ranges = append(ranges, scheduleReadFreeRange{Day: day, SlotStart: start, SlotEnd: 12})
|
||
}
|
||
return ranges
|
||
}
|
||
|
||
func findScheduleHostTaskBySlotForRead(state *schedule.ScheduleState, day int, section int) *schedule.ScheduleTask {
|
||
if state == nil {
|
||
return nil
|
||
}
|
||
for i := range state.Tasks {
|
||
task := &state.Tasks[i]
|
||
if task.EmbedHost != nil {
|
||
continue
|
||
}
|
||
for _, slot := range task.Slots {
|
||
if slot.Day == day && section >= slot.SlotStart && section <= slot.SlotEnd {
|
||
return task
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isCourseScheduleTaskForRead(task schedule.ScheduleTask) bool {
|
||
if strings.TrimSpace(task.Source) != "event" {
|
||
return false
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(task.EventType), "course") {
|
||
return true
|
||
}
|
||
return strings.TrimSpace(task.Category) == "课程"
|
||
}
|
||
|
||
func cloneAndSortTaskSlots(slots []schedule.TaskSlot) []schedule.TaskSlot {
|
||
if len(slots) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]schedule.TaskSlot, len(slots))
|
||
copy(out, slots)
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].Day != out[j].Day {
|
||
return out[i].Day < out[j].Day
|
||
}
|
||
if out[i].SlotStart != out[j].SlotStart {
|
||
return out[i].SlotStart < out[j].SlotStart
|
||
}
|
||
return out[i].SlotEnd < out[j].SlotEnd
|
||
})
|
||
return out
|
||
}
|
||
|
||
func fallbackText(text string, fallback string) string {
|
||
if strings.TrimSpace(text) == "" {
|
||
return strings.TrimSpace(fallback)
|
||
}
|
||
return strings.TrimSpace(text)
|
||
}
|