From 1a5b2ecd731359bf53909d689627dfc830d953b4 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Tue, 28 Apr 2026 15:52:13 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.51.dev.260428=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20schedule=20=E8=AF=BB=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=BB=93=E6=9E=9C=E6=AD=A3=E5=BC=8F=E5=88=87=E5=88=B0?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=20`schedule.read=5Fresult`=20?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E2=80=94=E2=80=94`get=5Foverview`=20/=20`que?= =?UTF-8?q?ry=5Frange`=20/=20`query=5Favailable=5Fslots`=20/=20`query=5Fta?= =?UTF-8?q?rget=5Ftasks`=20/=20`get=5Ftask=5Finfo`=20/=20`queue=5Fstatus`?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E7=8B=AC=E7=AB=8B=20handler=EF=BC=8C`Tool?= =?UTF-8?q?ExecutionResult`=20=E7=BB=A7=E7=BB=AD=E4=BF=9D=E7=95=99=20`Obse?= =?UTF-8?q?rvationText`=20=E7=BB=99=20LLM=EF=BC=8C=E4=BD=86=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=B1=95=E7=A4=BA=E6=94=B9=E8=B5=B0=20`result=5Fview`?= =?UTF-8?q?=20=E7=9A=84=20collapsed=20/=20expanded=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E7=BB=9F=E4=B8=80=E8=BE=93=E5=87=BA=20metrics=20/=20i?= =?UTF-8?q?tems=20/=20sections=20/=20machine=5Fpayload=202.=20`execution?= =?UTF-8?q?=5Fresult`=20=E8=A1=A5=E9=BD=90=E7=AC=AC=E4=BA=8C=E6=89=B9?= =?UTF-8?q?=E8=AF=BB=E5=B7=A5=E5=85=B7=E5=8F=82=E6=95=B0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=8C=96=E5=B1=95=E7=A4=BA=E2=80=94=E2=80=94=E6=89=A9=E5=B1=95?= =?UTF-8?q?=20`task=5Fids`=20/=20`task=5Fitem=5Fids`=20/=20`status`=20/=20?= =?UTF-8?q?`category`=20/=20`day=5Fscope`=20/=20`week=5Ffilter`=20/=20`slo?= =?UTF-8?q?t=5Ftypes`=20/=20`include=5Fpending`=20/=20`detail`=20/=20`dime?= =?UTF-8?q?nsions`=20=E7=AD=89=E5=8F=82=E6=95=B0=E7=9A=84=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E6=9D=83=E9=87=8D=E3=80=81=E4=B8=AD=E6=96=87=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E4=B8=8E=E5=B1=95=E7=A4=BA=E6=A0=BC=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=97=E8=A1=A8=20/=20=E5=B8=83=E5=B0=94?= =?UTF-8?q?=20/=20=E5=91=A8=E6=AC=A1=20/=20=E6=98=9F=E6=9C=9F=20/=20?= =?UTF-8?q?=E8=8A=82=E6=AC=A1=E7=AD=89=20`argument=5Fview`=20=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=203.=20ToolRegistry=20=E7=BB=A7=E7=BB=AD=E4=BB=8E?= =?UTF-8?q?=E5=86=85=E8=81=94=E6=B3=A8=E5=86=8C=E6=94=B6=E5=8F=A3=E5=88=B0?= =?UTF-8?q?=E4=B8=93=E5=B1=9E=20handler=E2=80=94=E2=80=94schedule=20?= =?UTF-8?q?=E8=AF=BB=E5=B7=A5=E5=85=B7=E4=BB=8E=20`wrapLegacyToolHandler`?= =?UTF-8?q?=20=E5=88=87=E5=88=B0=20`NewXxxToolHandler`=EF=BC=8C=E6=97=A7?= =?UTF-8?q?=20`schedule`=20=E5=AD=90=E5=8C=85=E9=87=8C=E7=9A=84=20observat?= =?UTF-8?q?ion=20=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91=E6=9A=82=E6=97=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=EF=BC=8C=E5=BD=93=E5=89=8D=E5=88=87=E6=B5=81?= =?UTF-8?q?=E7=82=B9=E5=B7=B2=E8=90=BD=E5=9C=A8=20`newAgent/tools`=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E9=80=82=E9=85=8D=E5=B1=82=204.=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8A=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E4=BA=A4=E6=8E=A5=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E3=80=8B=EF=BC=8C=E6=98=8E=E7=A1=AE=E7=AC=AC=E4=BA=8C=E6=89=B9?= =?UTF-8?q?=20read=20=E5=B7=A5=E5=85=B7=E5=B7=B2=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E3=80=81`schedule.read=5Fresult`=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E3=80=81=E5=BD=93=E5=89=8D=E6=97=A7=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=BE=B9=E7=95=8C=EF=BC=8C=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E8=BD=AE=E5=BB=BA=E8=AE=AE=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E7=9A=84=20`schedule=5Fanalysis`=20=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/newAgent/tools/execution_result.go | 329 ++++++++- backend/newAgent/tools/registry.go | 33 +- .../schedule_read_overview_queue_handlers.go | 383 +++++++++++ .../tools/schedule_read_result_common.go | 624 ++++++++++++++++++ .../tools/schedule_read_result_types.go | 112 ++++ .../tools/schedule_read_slots_handlers.go | 409 ++++++++++++ .../tools/schedule_read_tasks_handlers.go | 300 +++++++++ .../newAgent/tools/工具结果结构化交接文档.md | 258 ++++++++ 8 files changed, 2407 insertions(+), 41 deletions(-) create mode 100644 backend/newAgent/tools/schedule_read_overview_queue_handlers.go create mode 100644 backend/newAgent/tools/schedule_read_result_common.go create mode 100644 backend/newAgent/tools/schedule_read_result_types.go create mode 100644 backend/newAgent/tools/schedule_read_slots_handlers.go create mode 100644 backend/newAgent/tools/schedule_read_tasks_handlers.go create mode 100644 backend/newAgent/tools/工具结果结构化交接文档.md diff --git a/backend/newAgent/tools/execution_result.go b/backend/newAgent/tools/execution_result.go index 53c8ced..c710b63 100644 --- a/backend/newAgent/tools/execution_result.go +++ b/backend/newAgent/tools/execution_result.go @@ -471,18 +471,28 @@ func buildArgumentFields(toolName string, args map[string]any, state *schedule.S func argumentDisplayRank(key string) int { switch strings.TrimSpace(key) { - case "task_id", "task_a", "task_b": + case "task_id", "task_ids", "task_item_id", "task_item_ids", "task_a", "task_b": return 10 - case "day", "new_day": + case "status", "category": return 20 - case "slot_start", "new_slot_start": + case "day", "new_day", "day_start", "day_end", "day_scope", "day_of_week": return 30 - case "moves": + case "week", "week_filter", "week_from", "week_to": return 40 - case "reason": + case "slot_start", "new_slot_start", "slot_type", "slot_types", "exclude_sections", "after_section", "before_section", "section_from", "section_to": return 50 - default: + case "span", "duration": + return 60 + case "allow_embed", "enqueue", "reset_queue", "detail", "dimensions", "threshold", "include_pending", "hard_categories", "limit": + return 70 + case "moves": + return 80 + case "reason": + return 90 + case "query", "url": return 100 + default: + return 120 } } @@ -517,6 +527,12 @@ func resolveArgumentLabelCN(key string) string { switch strings.TrimSpace(key) { case "task_id": return "任务" + case "task_ids": + return "任务列表" + case "task_item_id": + return "任务项" + case "task_item_ids": + return "任务项列表" case "task_a": return "任务A" case "task_b": @@ -525,24 +541,79 @@ func resolveArgumentLabelCN(key string) string { return "目标日期" case "new_day": return "目标日期" + case "day_start": + return "起始日期" + case "day_end": + return "结束日期" + case "day_scope": + return "日期范围" + case "day_of_week": + return "星期过滤" + case "week": + return "周次" + case "week_filter": + return "周次过滤" + case "week_from": + return "起始周" + case "week_to": + return "结束周" case "slot_start": return "目标时段" case "new_slot_start": return "目标时段" + case "span": + return "连续时长" + case "duration": + return "时长" + case "allow_embed": + return "允许嵌入补位" + case "slot_type": + return "时段类型" + case "slot_types": + return "时段类型过滤" + case "exclude_sections": + return "排除节次" + case "after_section": + return "晚于节次" + case "before_section": + return "早于节次" + case "section_from": + return "起始节次" + case "section_to": + return "结束节次" case "moves": return "移动列表" case "reason": return "原因" case "status": return "状态" + case "category": + return "类别" case "limit": - return "数量" + return "数量上限" + case "enqueue": + return "加入队列" + case "reset_queue": + return "重置队列" + case "detail": + return "详情级别" + case "dimensions": + return "分析维度" + case "threshold": + return "阈值" + case "include_pending": + return "包含待安排" + case "hard_categories": + return "强约束类别" case "query": return "查询内容" case "url": return "链接" default: - return "参数" + if strings.TrimSpace(key) == "" { + return "参数" + } + return strings.TrimSpace(key) } } @@ -553,14 +624,33 @@ func formatArgumentDisplay( args map[string]any, state *schedule.ScheduleState, ) string { + _ = toolName switch key { - case "task_id", "task_a", "task_b": + case "task_id", "task_item_id", "task_a", "task_b": if taskID, ok := toInt(value); ok { return resolveTaskLabelByID(state, taskID, true) } - case "day", "new_day": + case "task_ids", "task_item_ids": + return formatTaskIDListArgumentCN(value, state) + case "day", "new_day", "day_start", "day_end": if day, ok := toInt(value); ok { - return formatDayLabelCN(day) + return formatScheduleDayCN(state, day) + } + case "day_scope": + if text, ok := value.(string); ok { + return formatDayScopeLabelCN(text) + } + case "day_of_week": + return formatWeekdaySliceArgumentCN(value) + case "week": + if week, ok := toInt(value); ok { + return fmt.Sprintf("第%d周", week) + } + case "week_filter": + return formatWeekSliceArgumentCN(value) + case "week_from", "week_to": + if week, ok := toInt(value); ok { + return fmt.Sprintf("第%d周", week) } case "slot_start", "new_slot_start": if slotStart, ok := toInt(value); ok { @@ -569,15 +659,58 @@ func formatArgumentDisplay( if task := stateTaskByID(state, taskID); task != nil && task.Duration > 1 { slotEnd = slotStart + task.Duration - 1 } + } else if duration, ok := toInt(args["duration"]); ok && duration > 1 { + slotEnd = slotStart + duration - 1 + } else if span, ok := toInt(args["span"]); ok && span > 1 { + slotEnd = slotStart + span - 1 } if day, ok := toInt(args["day"]); ok { - return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd)) + return fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatSlotRangeCN(slotStart, slotEnd)) } if day, ok := toInt(args["new_day"]); ok { - return fmt.Sprintf("%s%s", formatDayLabelCN(day), formatSlotRangeCN(slotStart, slotEnd)) + return fmt.Sprintf("%s %s", formatScheduleDayCN(state, day), formatSlotRangeCN(slotStart, slotEnd)) } return formatSlotRangeCN(slotStart, slotEnd) } + case "slot_type": + if text, ok := value.(string); ok { + return formatSlotTypeLabelCN(text) + } + case "slot_types": + return formatSlotTypeListArgumentCN(value) + case "exclude_sections": + return formatSectionSliceArgumentCN(value) + case "after_section", "before_section", "section_from", "section_to": + if section, ok := toInt(value); ok { + return fmt.Sprintf("第%d节", section) + } + case "span", "duration": + if count, ok := toInt(value); ok { + return fmt.Sprintf("%d 节", count) + } + case "allow_embed", "enqueue", "reset_queue", "include_pending": + if enabled, ok := toBool(value); ok { + return formatBoolLabelCN(enabled) + } + case "status": + if text, ok := value.(string); ok { + return formatTargetPoolStatusCN(text) + } + case "category": + return fallbackText(formatAnyValueCN(value), "未分类") + case "detail": + if text, ok := value.(string); ok { + switch strings.ToLower(strings.TrimSpace(text)) { + case "summary": + return "摘要" + case "full": + return "完整" + default: + return fallbackText(text, "未标注") + } + } + case "dimensions", "hard_categories": + return formatStringSliceArgumentCN(value) case "moves": return formatMovesArgumentCN(value, state) } @@ -646,8 +779,39 @@ func formatAnyValueCN(value any) string { return "是" } return "否" + case []string: + return formatStringSliceArgumentCN(typed) + case []int: + if len(typed) == 0 { + return "空" + } + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, fmt.Sprintf("%d", item)) + } + return strings.Join(parts, "、") + case []float64: + if len(typed) == 0 { + return "空" + } + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, fmt.Sprintf("%g", item)) + } + return strings.Join(parts, "、") case []any: - return fmt.Sprintf("%d 项", len(typed)) + if len(typed) == 0 { + return "空" + } + parts := make([]string, 0, len(typed)) + for index, item := range typed { + if index >= 4 { + parts = append(parts, fmt.Sprintf("等 %d 项", len(typed))) + break + } + parts = append(parts, formatAnyValueCN(item)) + } + return strings.Join(parts, "、") default: if value == nil { return "空" @@ -660,6 +824,143 @@ func formatAnyValueCN(value any) string { } } +func formatTaskIDListArgumentCN(value any, state *schedule.ScheduleState) string { + ids := toIntSliceAny(value) + if len(ids) == 0 { + return formatAnyValueCN(value) + } + parts := make([]string, 0, len(ids)) + for index, id := range ids { + if index >= 4 { + parts = append(parts, fmt.Sprintf("等 %d 项", len(ids))) + break + } + parts = append(parts, resolveTaskLabelByID(state, id, true)) + } + return strings.Join(parts, "、") +} + +func formatWeekdaySliceArgumentCN(value any) string { + days := toIntSliceAny(value) + if len(days) == 0 { + return formatAnyValueCN(value) + } + return formatWeekdayListCN(days) +} + +func formatWeekSliceArgumentCN(value any) string { + weeks := toIntSliceAny(value) + if len(weeks) == 0 { + return formatAnyValueCN(value) + } + return formatScheduleWeekListCN(weeks) +} + +func formatSectionSliceArgumentCN(value any) string { + sections := toIntSliceAny(value) + if len(sections) == 0 { + return formatAnyValueCN(value) + } + return formatScheduleSectionListCN(sections) +} + +func formatSlotTypeListArgumentCN(value any) string { + items := toStringSliceAny(value) + if len(items) == 0 { + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + return formatSlotTypeLabelCN(text) + } + return "空" + } + parts := make([]string, 0, len(items)) + for _, item := range items { + parts = append(parts, formatSlotTypeLabelCN(item)) + } + return strings.Join(parts, "、") +} + +func formatStringSliceArgumentCN(value any) string { + items := toStringSliceAny(value) + if len(items) == 0 { + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + return "空" + } + return strings.Join(items, "、") +} + +func toIntSliceAny(value any) []int { + switch typed := value.(type) { + case []int: + out := make([]int, len(typed)) + copy(out, typed) + return out + case []float64: + out := make([]int, 0, len(typed)) + for _, item := range typed { + out = append(out, int(item)) + } + return out + case []any: + out := make([]int, 0, len(typed)) + for _, item := range typed { + number, ok := toInt(item) + if !ok { + continue + } + out = append(out, number) + } + return out + default: + return nil + } +} + +func toStringSliceAny(value any) []string { + switch typed := value.(type) { + case []string: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if strings.TrimSpace(item) == "" { + continue + } + out = append(out, strings.TrimSpace(item)) + } + return out + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + text := strings.TrimSpace(fmt.Sprintf("%v", item)) + if text == "" || text == "" { + continue + } + out = append(out, text) + } + return out + default: + return nil + } +} + +func toBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "true", "1", "yes": + return true, true + case "false", "0", "no": + return false, true + default: + return false, false + } + default: + return false, false + } +} + func resolveTaskLabelByID(state *schedule.ScheduleState, taskID int, withID bool) string { if taskID <= 0 { return "未知任务" diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go index 4f36ddf..35e2ee3 100644 --- a/backend/newAgent/tools/registry.go +++ b/backend/newAgent/tools/registry.go @@ -306,38 +306,25 @@ func registerScheduleReadTools(r *ToolRegistry) { "get_overview", "获取当前窗口总览:保留课程占位统计,展开任务清单。", `{"name":"get_overview","parameters":{}}`, - wrapLegacyToolHandler("get_overview", func(state *schedule.ScheduleState, args map[string]any) string { - _ = args - return schedule.GetOverview(state) - }), + NewGetOverviewToolHandler(), ) r.Register( "query_range", "查看某天或某时段的占用详情。day 必填,slot_start/slot_end 选填。", `{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`, - wrapLegacyToolHandler("query_range", func(state *schedule.ScheduleState, args map[string]any) string { - day, ok := schedule.ArgsInt(args, "day") - if !ok { - return "查询失败:缺少必填参数 day。" - } - return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end")) - }), + NewQueryRangeToolHandler(), ) r.Register( "query_available_slots", "查询候选空位池,适合 move 前筛落点。", `{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`, - wrapLegacyToolHandler("query_available_slots", func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.QueryAvailableSlots(state, args) - }), + NewQueryAvailableSlotsToolHandler(), ) r.Register( "query_target_tasks", "查询候选任务集合,可按 status/week/day/task_id/category 筛选;支持 enqueue。", `{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`, - wrapLegacyToolHandler("query_target_tasks", func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.QueryTargetTasks(state, args) - }), + NewQueryTargetTasksToolHandler(), ) r.Register( "queue_pop_head", @@ -351,21 +338,13 @@ func registerScheduleReadTools(r *ToolRegistry) { "queue_status", "查看当前队列状态(pending/current/completed/skipped)。", `{"name":"queue_status","parameters":{}}`, - wrapLegacyToolHandler("queue_status", func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.QueueStatus(state, args) - }), + NewQueueStatusToolHandler(), ) r.Register( "get_task_info", "查看单个任务详情,包括类别、状态与落位。", `{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`, - wrapLegacyToolHandler("get_task_info", func(state *schedule.ScheduleState, args map[string]any) string { - taskID, ok := schedule.ArgsInt(args, "task_id") - if !ok { - return "查询失败:缺少必填参数 task_id。" - } - return schedule.GetTaskInfo(state, taskID) - }), + NewGetTaskInfoToolHandler(), ) } diff --git a/backend/newAgent/tools/schedule_read_overview_queue_handlers.go b/backend/newAgent/tools/schedule_read_overview_queue_handlers.go new file mode 100644 index 0000000..c1e7248 --- /dev/null +++ b/backend/newAgent/tools/schedule_read_overview_queue_handlers.go @@ -0,0 +1,383 @@ +package newagenttools + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// NewGetOverviewToolHandler 为 get_overview 生成结构化读结果。 +func NewGetOverviewToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + if state == nil { + return buildScheduleReadSimpleFailureResult("get_overview", args, nil, "查看总览失败:日程状态为空,无法读取总览。") + } + + observation := schedule.GetOverview(state) + totalSlots := state.Window.TotalDays * 12 + totalOccupied := 0 + taskExistingCount := 0 + taskSuggestedCount := 0 + taskPendingCount := 0 + courseExistingCount := 0 + + for i := range state.Tasks { + task := state.Tasks[i] + if task.EmbedHost == nil { + for _, slot := range task.Slots { + totalOccupied += slot.SlotEnd - slot.SlotStart + 1 + } + } + if isCourseScheduleTaskForRead(task) { + if schedule.IsExistingTask(task) { + courseExistingCount++ + } + continue + } + switch { + case schedule.IsPendingTask(task): + taskPendingCount++ + case schedule.IsSuggestedTask(task): + taskSuggestedCount++ + default: + taskExistingCount++ + } + } + + dailyItems := make([]map[string]any, 0, state.Window.TotalDays) + for day := 1; day <= state.Window.TotalDays; day++ { + totalDayOccupied := countScheduleDayOccupiedForRead(state, day) + taskDayOccupied := countScheduleDayTaskOccupiedForRead(state, day) + taskEntries := listScheduleTasksOnDayForRead(state, day, false) + detailLines := make([]string, 0, len(taskEntries)) + for _, entry := range taskEntries { + detailLines = append(detailLines, fmt.Sprintf( + "[%d]%s|%s|%s", + entry.Task.StateID, + fallbackText(entry.Task.Name, "未命名任务"), + formatScheduleTaskStatusCN(*entry.Task), + formatScheduleSlotRangeCN(entry.SlotStart, entry.SlotEnd), + )) + } + if len(detailLines) == 0 { + detailLines = append(detailLines, "当天没有任务明细。") + } + dailyItems = append(dailyItems, buildScheduleReadItem( + formatScheduleDayCN(state, day), + fmt.Sprintf("总占用 %d/12 节,任务占用 %d/12 节", totalDayOccupied, taskDayOccupied), + []string{fmt.Sprintf("任务 %d 项", len(taskEntries))}, + detailLines, + map[string]any{"day": day}, + )) + } + + taskItems := make([]map[string]any, 0, len(state.Tasks)) + for i := range state.Tasks { + task := state.Tasks[i] + if isCourseScheduleTaskForRead(task) { + continue + } + detailLines := []string{ + "时段:" + formatScheduleTaskSlotsBriefCN(state, task.Slots), + "来源:" + formatScheduleTaskSourceCN(task), + } + if task.TaskClassID > 0 { + detailLines = append(detailLines, fmt.Sprintf("任务类 ID:%d", task.TaskClassID)) + } + taskItems = append(taskItems, buildScheduleReadItem( + fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")), + fmt.Sprintf("%s|%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(task)), + []string{formatScheduleTaskStatusCN(task)}, + detailLines, + map[string]any{ + "task_id": task.StateID, + "task_class_id": task.TaskClassID, + "status": task.Status, + }, + )) + } + sort.Slice(taskItems, func(i, j int) bool { + leftID, _ := toInt(taskItems[i]["meta"].(map[string]any)["task_id"]) + rightID, _ := toInt(taskItems[j]["meta"].(map[string]any)["task_id"]) + return leftID < rightID + }) + + taskClassItems := make([]map[string]any, 0, len(state.TaskClasses)) + for _, meta := range state.TaskClasses { + detailLines := []string{ + fmt.Sprintf("排程策略:%s", formatTaskClassStrategyCN(meta.Strategy)), + fmt.Sprintf("总预算:%d 节", meta.TotalSlots), + fmt.Sprintf("允许嵌入水课:%s", formatBoolLabelCN(meta.AllowFillerCourse)), + } + if len(meta.ExcludedSlots) > 0 { + detailLines = append(detailLines, "排除节次:"+formatScheduleSectionListCN(meta.ExcludedSlots)) + } + if len(meta.ExcludedDaysOfWeek) > 0 { + detailLines = append(detailLines, "排除星期:"+formatWeekdayListCN(meta.ExcludedDaysOfWeek)) + } + taskClassItems = append(taskClassItems, buildScheduleReadItem( + fallbackText(meta.Name, "未命名任务类"), + formatTaskClassStrategyCN(meta.Strategy), + nil, + detailLines, + map[string]any{ + "task_class_id": meta.ID, + "strategy": meta.Strategy, + }, + )) + } + + totalFree := totalSlots - totalOccupied + if totalFree < 0 { + totalFree = 0 + } + sections := []map[string]any{ + buildScheduleReadKVSection("窗口概况", []map[string]any{ + buildScheduleReadKV("规划天数", fmt.Sprintf("%d 天", state.Window.TotalDays)), + buildScheduleReadKV("总时段", fmt.Sprintf("%d 节", totalSlots)), + buildScheduleReadKV("已占用", fmt.Sprintf("%d 节", totalOccupied)), + buildScheduleReadKV("空闲", fmt.Sprintf("%d 节", totalFree)), + buildScheduleReadKV("课程占位", fmt.Sprintf("%d 项", courseExistingCount)), + buildScheduleReadKV("已安排任务", fmt.Sprintf("%d 项", taskExistingCount)), + buildScheduleReadKV("已预排任务", fmt.Sprintf("%d 项", taskSuggestedCount)), + buildScheduleReadKV("待安排任务", fmt.Sprintf("%d 项", taskPendingCount)), + }), + buildScheduleReadItemsSection("每日概况", dailyItems), + buildScheduleReadItemsSection("任务清单", taskItems), + } + if len(taskClassItems) > 0 { + sections = append(sections, buildScheduleReadItemsSection("任务类约束", taskClassItems)) + } + + return buildScheduleReadResult( + "get_overview", + args, + state, + observation, + ToolStatusDone, + "当前排程总览", + fmt.Sprintf("%d 天窗口,已占用 %d/%d 节,待安排 %d 项。", state.Window.TotalDays, totalOccupied, totalSlots, taskPendingCount), + []map[string]any{ + buildScheduleReadMetric("已占用", fmt.Sprintf("%d 节", totalOccupied)), + buildScheduleReadMetric("空闲", fmt.Sprintf("%d 节", totalFree)), + buildScheduleReadMetric("待安排", fmt.Sprintf("%d 项", taskPendingCount)), + buildScheduleReadMetric("课程占位", fmt.Sprintf("%d 项", courseExistingCount)), + }, + dailyItems, + sections, + map[string]any{ + "total_days": state.Window.TotalDays, + "total_slots": totalSlots, + "total_occupied": totalOccupied, + "task_existing_count": taskExistingCount, + "task_suggested_count": taskSuggestedCount, + "task_pending_count": taskPendingCount, + "course_existing_count": courseExistingCount, + }, + ) + } +} + +// NewQueueStatusToolHandler 为 queue_status 生成结构化读结果。 +func NewQueueStatusToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + observation := schedule.QueueStatus(state, args) + if state == nil { + return buildScheduleReadSimpleFailureResult("queue_status", args, nil, observation) + } + + payload, machinePayload := decodeQueueStatusPayload(observation) + items := make([]map[string]any, 0, 1+len(payload.NextTaskIDs)) + sections := make([]map[string]any, 0, 4) + if payload.Current != nil { + currentItem := buildQueueCurrentItem(state, payload.Current, payload.CurrentAttempt) + items = append(items, currentItem) + sections = append(sections, buildScheduleReadItemsSection("当前处理", []map[string]any{currentItem})) + } + + nextItems := make([]map[string]any, 0, len(payload.NextTaskIDs)) + for index, taskID := range payload.NextTaskIDs { + nextItems = append(nextItems, buildQueuePendingItem(state, taskID, index)) + } + items = append(items, nextItems...) + if len(nextItems) > 0 { + sections = append(sections, buildScheduleReadItemsSection("待处理队列", nextItems)) + } + + sections = append(sections, buildScheduleReadKVSection("运行概况", []map[string]any{ + buildScheduleReadKV("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), + buildScheduleReadKV("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), + buildScheduleReadKV("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)), + buildScheduleReadKV("当前任务", resolveTaskQueueLabelByID(state, payload.CurrentTaskID)), + })) + if strings.TrimSpace(payload.LastError) != "" { + sections = append(sections, buildScheduleReadCalloutSection( + "最近一次失败", + "队列中保留了上一轮 apply 的失败原因。", + "warning", + []string{strings.TrimSpace(payload.LastError)}, + )) + } + + title := fmt.Sprintf("队列待处理 %d 项", payload.PendingCount) + if payload.PendingCount == 0 && payload.CurrentTaskID == 0 { + title = "当前队列为空" + } + return buildScheduleReadResult( + "queue_status", + args, + state, + observation, + ToolStatusDone, + title, + buildQueueStatusSubtitle(state, payload), + []map[string]any{ + buildScheduleReadMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), + buildScheduleReadMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), + buildScheduleReadMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)), + }, + items, + sections, + machinePayload, + ) + } +} + +func decodeQueueStatusPayload(observation string) (scheduleReadQueueStatusPayload, map[string]any) { + var payload scheduleReadQueueStatusPayload + _ = json.Unmarshal([]byte(strings.TrimSpace(observation)), &payload) + raw, _ := parseObservationJSON(strings.TrimSpace(observation)) + return payload, raw +} + +func buildQueueStatusSubtitle(state *schedule.ScheduleState, payload scheduleReadQueueStatusPayload) string { + if payload.Current != nil { + return fmt.Sprintf( + "当前处理:[%d]%s,第 %d 次尝试。", + payload.Current.TaskID, + fallbackText(payload.Current.Name, "未命名任务"), + maxInt(payload.CurrentAttempt, 1), + ) + } + if payload.PendingCount > 0 { + return fmt.Sprintf("队列里还有 %d 项待处理,尚未弹出当前任务。", payload.PendingCount) + } + return "没有待处理任务,也没有正在处理的任务。" +} + +// 这里没有强抽成公共 task builder,因为 queue_status 既要兼容 payload 快照, +// 也要兼容通过 state 按 task_id 兜底,两类输入结构不同,硬抽反而会增加适配噪音。 +func buildQueueCurrentItem(state *schedule.ScheduleState, payload *scheduleReadQueueTaskSnapshot, attempt int) map[string]any { + detailLines := buildQueueCurrentDetailLines(state, payload) + detailLines = append(detailLines, fmt.Sprintf("当前尝试:第 %d 次", maxInt(attempt, 1))) + return buildScheduleReadItem( + fmt.Sprintf("[%d]%s", payload.TaskID, fallbackText(payload.Name, "未命名任务")), + buildQueueCurrentSubtitle(payload), + []string{"当前处理"}, + detailLines, + map[string]any{ + "task_id": payload.TaskID, + "status": payload.Status, + "task_class_id": payload.TaskClassID, + }, + ) +} + +func buildQueuePendingItem(state *schedule.ScheduleState, taskID int, index int) map[string]any { + task := state.TaskByStateID(taskID) + if task == nil { + return buildScheduleReadItem( + fmt.Sprintf("[%d]任务", taskID), + fmt.Sprintf("队列第 %d 位", index+1), + []string{"待处理"}, + []string{"当前状态快照中未找到更多任务详情。"}, + map[string]any{"task_id": taskID, "queue_index": index}, + ) + } + return buildScheduleReadItem( + fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")), + buildQueueTaskSubtitle(task), + buildQueueTaskTags(task, false), + buildQueueTaskDetailLines(state, task), + map[string]any{ + "task_id": task.StateID, + "queue_index": index, + "status": task.Status, + }, + ) +} + +func buildQueueTaskSubtitle(task *schedule.ScheduleTask) string { + if task == nil { + return "待处理" + } + return fmt.Sprintf("%s|%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task)) +} + +func buildQueueTaskTags(task *schedule.ScheduleTask, isCurrent bool) []string { + tags := []string{} + if isCurrent { + tags = append(tags, "当前处理") + } else { + tags = append(tags, "待处理") + } + if task != nil && task.Duration > 0 { + tags = append(tags, fmt.Sprintf("%d 节", task.Duration)) + } + return tags +} + +func buildQueueTaskDetailLines(state *schedule.ScheduleState, task *schedule.ScheduleTask) []string { + if task == nil { + return nil + } + lines := []string{"时段:" + formatScheduleTaskSlotsBriefCN(state, task.Slots)} + if task.TaskClassID > 0 { + lines = append(lines, fmt.Sprintf("任务类 ID:%d", task.TaskClassID)) + } + return lines +} + +func buildQueueCurrentSubtitle(payload *scheduleReadQueueTaskSnapshot) string { + if payload == nil { + return "当前处理" + } + return fmt.Sprintf("%s|%s", fallbackText(payload.Category, "未分类"), formatTargetTaskStatusCN(payload.Status)) +} + +func buildQueueCurrentDetailLines(state *schedule.ScheduleState, payload *scheduleReadQueueTaskSnapshot) []string { + if payload == nil { + return nil + } + lines := make([]string, 0, 3) + if len(payload.Slots) > 0 { + slotParts := make([]string, 0, len(payload.Slots)) + for _, slot := range payload.Slots { + slotParts = append(slotParts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd)) + } + lines = append(lines, "时段:"+strings.Join(slotParts, ";")) + } else { + lines = append(lines, "当前还未落位。") + } + if payload.TaskClassID > 0 { + lines = append(lines, fmt.Sprintf("任务类 ID:%d", payload.TaskClassID)) + } + if payload.Duration > 0 { + lines = append(lines, fmt.Sprintf("时长需求:%d 节", payload.Duration)) + } + return lines +} + +func formatTaskClassStrategyCN(strategy string) string { + switch strings.TrimSpace(strategy) { + case "steady": + return "均匀分布" + case "rapid": + return "集中突击" + default: + return fallbackText(strategy, "默认") + } +} diff --git a/backend/newAgent/tools/schedule_read_result_common.go b/backend/newAgent/tools/schedule_read_result_common.go new file mode 100644 index 0000000..099ff0b --- /dev/null +++ b/backend/newAgent/tools/schedule_read_result_common.go @@ -0,0 +1,624 @@ +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) +} diff --git a/backend/newAgent/tools/schedule_read_result_types.go b/backend/newAgent/tools/schedule_read_result_types.go new file mode 100644 index 0000000..c96f1b9 --- /dev/null +++ b/backend/newAgent/tools/schedule_read_result_types.go @@ -0,0 +1,112 @@ +package newagenttools + +import "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + +const scheduleReadResultViewType = "schedule.read_result" + +type scheduleReadTaskOnDay struct { + Task *schedule.ScheduleTask + SlotStart int + SlotEnd int +} + +type scheduleReadFreeRange struct { + Day int + SlotStart int + SlotEnd int +} + +type scheduleReadAvailableSlotsPayload struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Error string `json:"error"` + Count int `json:"count"` + StrictCount int `json:"strict_count"` + EmbeddedCount int `json:"embedded_count"` + FallbackUsed bool `json:"fallback_used"` + DayScope string `json:"day_scope"` + DayOfWeek []int `json:"day_of_week"` + WeekFilter []int `json:"week_filter"` + WeekFrom int `json:"week_from"` + WeekTo int `json:"week_to"` + Span int `json:"span"` + AllowEmbed bool `json:"allow_embed"` + ExcludeSections []int `json:"exclude_sections"` + Slots []scheduleReadAvailableSlotRecord `json:"slots"` +} + +type scheduleReadAvailableSlotRecord struct { + Day int `json:"day"` + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SlotStart int `json:"slot_start"` + SlotEnd int `json:"slot_end"` + SlotType string `json:"slot_type"` +} + +type scheduleReadTargetTasksPayload struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Error string `json:"error"` + Count int `json:"count"` + Status string `json:"status"` + DayScope string `json:"day_scope"` + DayOfWeek []int `json:"day_of_week"` + WeekFilter []int `json:"week_filter"` + WeekFrom int `json:"week_from"` + WeekTo int `json:"week_to"` + Enqueue bool `json:"enqueue"` + Enqueued int `json:"enqueued"` + Queue *scheduleReadTargetTasksQueueRecord `json:"queue"` + Items []scheduleReadTargetTaskRecord `json:"items"` +} + +type scheduleReadTargetTasksQueueRecord struct { + PendingCount int `json:"pending_count"` + CompletedCount int `json:"completed_count"` + SkippedCount int `json:"skipped_count"` + CurrentTaskID int `json:"current_task_id"` + CurrentAttempt int `json:"current_attempt"` +} + +type scheduleReadTargetTaskRecord struct { + TaskID int `json:"task_id"` + Name string `json:"name"` + Category string `json:"category"` + Status string `json:"status"` + Duration int `json:"duration"` + TaskClassID int `json:"task_class_id"` + Slots []scheduleReadTargetTaskSlotInfo `json:"slots"` +} + +type scheduleReadTargetTaskSlotInfo struct { + Day int `json:"day"` + Week int `json:"week"` + DayOfWeek int `json:"day_of_week"` + SlotStart int `json:"slot_start"` + SlotEnd int `json:"slot_end"` +} + +type scheduleReadQueueStatusPayload struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Error string `json:"error"` + PendingCount int `json:"pending_count"` + CompletedCount int `json:"completed_count"` + SkippedCount int `json:"skipped_count"` + CurrentTaskID int `json:"current_task_id"` + CurrentAttempt int `json:"current_attempt"` + LastError string `json:"last_error"` + NextTaskIDs []int `json:"next_task_ids"` + Current *scheduleReadQueueTaskSnapshot `json:"current"` +} + +type scheduleReadQueueTaskSnapshot struct { + TaskID int `json:"task_id"` + Name string `json:"name"` + Category string `json:"category"` + Status string `json:"status"` + Duration int `json:"duration"` + TaskClassID int `json:"task_class_id"` + Slots []scheduleReadTargetTaskSlotInfo `json:"slots"` +} diff --git a/backend/newAgent/tools/schedule_read_slots_handlers.go b/backend/newAgent/tools/schedule_read_slots_handlers.go new file mode 100644 index 0000000..98fe524 --- /dev/null +++ b/backend/newAgent/tools/schedule_read_slots_handlers.go @@ -0,0 +1,409 @@ +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, "未分类"), + ) +} diff --git a/backend/newAgent/tools/schedule_read_tasks_handlers.go b/backend/newAgent/tools/schedule_read_tasks_handlers.go new file mode 100644 index 0000000..ec6dcdc --- /dev/null +++ b/backend/newAgent/tools/schedule_read_tasks_handlers.go @@ -0,0 +1,300 @@ +package newagenttools + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// NewQueryTargetTasksToolHandler 为 query_target_tasks 生成结构化读结果。 +func NewQueryTargetTasksToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + observation := schedule.QueryTargetTasks(state, args) + status, _ := resolveToolStatusAndSuccess(observation) + if status != ToolStatusDone { + return buildScheduleReadSimpleFailureResult("query_target_tasks", args, state, observation) + } + + payload, machinePayload := decodeTargetTasksPayload(observation) + items := make([]map[string]any, 0, len(payload.Items)) + for _, item := range payload.Items { + items = append(items, buildScheduleReadItem( + fmt.Sprintf("[%d]%s", item.TaskID, fallbackText(item.Name, "未命名任务")), + buildTargetTaskSubtitle(item), + buildTargetTaskTags(item), + buildTargetTaskDetailLines(state, item), + map[string]any{ + "task_id": item.TaskID, + "category": item.Category, + "status": item.Status, + "duration": item.Duration, + "task_class_id": item.TaskClassID, + }, + )) + } + + metrics := []map[string]any{ + buildScheduleReadMetric("候选任务", fmt.Sprintf("%d 项", payload.Count)), + buildScheduleReadMetric("任务池", formatTargetPoolStatusCN(payload.Status)), + } + if payload.Enqueue { + metrics = append(metrics, buildScheduleReadMetric("已入队", fmt.Sprintf("%d 项", payload.Enqueued))) + } + + sections := []map[string]any{ + buildScheduleReadKVSection("筛选概况", []map[string]any{ + buildScheduleReadKV("任务池", formatTargetPoolStatusCN(payload.Status)), + buildScheduleReadKV("日期范围", formatDayScopeLabelCN(payload.DayScope)), + buildScheduleReadKV("星期过滤", formatWeekdayListCN(payload.DayOfWeek)), + buildScheduleReadKV("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)), + buildScheduleReadKV("是否入队", formatBoolLabelCN(payload.Enqueue)), + }), + } + appendSectionIfPresent(§ions, buildScheduleReadArgsSection("筛选条件", LegacyResultWithState("query_target_tasks", args, state, observation).ArgumentView)) + if payload.Queue != nil { + sections = append(sections, buildScheduleReadKVSection("队列状态", []map[string]any{ + buildScheduleReadKV("待处理", fmt.Sprintf("%d 项", payload.Queue.PendingCount)), + buildScheduleReadKV("已完成", fmt.Sprintf("%d 项", payload.Queue.CompletedCount)), + buildScheduleReadKV("已跳过", fmt.Sprintf("%d 项", payload.Queue.SkippedCount)), + buildScheduleReadKV("当前任务", resolveTaskQueueLabelByID(state, payload.Queue.CurrentTaskID)), + })) + } + if len(items) > 0 { + sections = append(sections, buildScheduleReadItemsSection("候选任务", items)) + } else { + sections = append(sections, buildScheduleReadCalloutSection( + "没有命中任务", + "当前筛选条件下没有找到候选任务。", + "info", + []string{"可以放宽状态、日期或任务 ID 过滤条件后再试。"}, + )) + } + + title := fmt.Sprintf("找到 %d 个候选任务", payload.Count) + if payload.Count == 0 { + title = "未找到候选任务" + } + return buildScheduleReadResult( + "query_target_tasks", + args, + state, + observation, + ToolStatusDone, + title, + buildTargetTasksSummarySubtitle(payload), + metrics, + items, + sections, + machinePayload, + ) + } +} + +// NewGetTaskInfoToolHandler 为 get_task_info 生成结构化读结果。 +func NewGetTaskInfoToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + taskID, ok := schedule.ArgsInt(args, "task_id") + if !ok { + return buildScheduleReadSimpleFailureResult("get_task_info", args, state, "查询失败:缺少必填参数 task_id。") + } + if state == nil { + return buildScheduleReadSimpleFailureResult("get_task_info", args, nil, "查询失败:日程状态为空,无法读取任务详情。") + } + + observation := schedule.GetTaskInfo(state, taskID) + task := state.TaskByStateID(taskID) + if task == nil { + return buildScheduleReadSimpleFailureResult("get_task_info", args, state, observation) + } + + slotItems := make([]map[string]any, 0, len(task.Slots)) + for _, slot := range cloneAndSortTaskSlots(task.Slots) { + slotItems = append(slotItems, buildScheduleReadItem( + formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd), + formatScheduleTaskStatusCN(*task), + []string{fallbackText(task.Category, "未分类")}, + []string{ + fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*task)), + fmt.Sprintf("时长:%d 节", slot.SlotEnd-slot.SlotStart+1), + }, + map[string]any{ + "day": slot.Day, + "slot_start": slot.SlotStart, + "slot_end": slot.SlotEnd, + }, + )) + } + + fields := []map[string]any{ + buildScheduleReadKV("类别", fallbackText(task.Category, "未分类")), + buildScheduleReadKV("状态", formatScheduleTaskStatusCN(*task)), + buildScheduleReadKV("来源", formatScheduleTaskSourceCN(*task)), + buildScheduleReadKV("落位情况", buildTaskPlacementLabel(task)), + buildScheduleReadKV("时长需求", buildTaskDurationLabel(task)), + } + if task.TaskClassID > 0 { + fields = append(fields, buildScheduleReadKV("任务类 ID", fmt.Sprintf("%d", task.TaskClassID))) + } + if task.CanEmbed { + fields = append(fields, buildScheduleReadKV("可作为宿主", "是")) + } + + sections := []map[string]any{ + buildScheduleReadKVSection("基本信息", fields), + } + if len(slotItems) > 0 { + sections = append(sections, buildScheduleReadItemsSection("占用时段", slotItems)) + } + if relationLines := buildTaskRelationLines(state, task); len(relationLines) > 0 { + sections = append(sections, buildScheduleReadCalloutSection("嵌入关系", "当前任务存在宿主/客体关系。", "info", relationLines)) + } + appendSectionIfPresent(§ions, buildScheduleReadArgsSection("查询条件", LegacyResultWithState("get_task_info", args, state, observation).ArgumentView)) + + return buildScheduleReadResult( + "get_task_info", + args, + state, + observation, + ToolStatusDone, + fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")), + fmt.Sprintf("%s|%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task)), + []map[string]any{ + buildScheduleReadMetric("状态", formatScheduleTaskStatusCN(*task)), + buildScheduleReadMetric("时长", buildTaskDurationLabel(task)), + buildScheduleReadMetric("落位", buildTaskPlacementLabel(task)), + }, + slotItems, + sections, + map[string]any{ + "task_id": task.StateID, + "source": task.Source, + "status": task.Status, + "task_class_id": task.TaskClassID, + "can_embed": task.CanEmbed, + "embedded_by": task.EmbeddedBy, + "embed_host": task.EmbedHost, + }, + ) + } +} + +func decodeTargetTasksPayload(observation string) (scheduleReadTargetTasksPayload, map[string]any) { + var payload scheduleReadTargetTasksPayload + _ = json.Unmarshal([]byte(strings.TrimSpace(observation)), &payload) + raw, _ := parseObservationJSON(strings.TrimSpace(observation)) + return payload, raw +} + +func buildTargetTasksSummarySubtitle(payload scheduleReadTargetTasksPayload) string { + parts := []string{ + formatTargetPoolStatusCN(payload.Status), + formatDayScopeLabelCN(payload.DayScope), + buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter), + } + if len(payload.DayOfWeek) > 0 { + parts = append(parts, formatWeekdayListCN(payload.DayOfWeek)) + } + if payload.Enqueue { + parts = append(parts, fmt.Sprintf("已入队 %d 项", payload.Enqueued)) + } + return strings.Join(parts, ",") +} + +func buildTargetTaskSubtitle(item scheduleReadTargetTaskRecord) string { + return fmt.Sprintf("%s|%s", fallbackText(item.Category, "未分类"), formatTargetTaskStatusCN(item.Status)) +} + +func buildTargetTaskTags(item scheduleReadTargetTaskRecord) []string { + tags := []string{formatTargetTaskStatusCN(item.Status)} + if item.Duration > 0 { + tags = append(tags, fmt.Sprintf("%d 节", item.Duration)) + } + if item.TaskClassID > 0 { + tags = append(tags, fmt.Sprintf("任务类 %d", item.TaskClassID)) + } + return tags +} + +func buildTargetTaskDetailLines(state *schedule.ScheduleState, item scheduleReadTargetTaskRecord) []string { + lines := make([]string, 0, 3) + if len(item.Slots) == 0 { + lines = append(lines, fmt.Sprintf("当前未落位,仍需要 %s。", buildTaskDurationText(item.Duration))) + } else { + slotParts := make([]string, 0, len(item.Slots)) + for _, slot := range item.Slots { + slotParts = append(slotParts, formatScheduleDaySlotCN(state, slot.Day, slot.SlotStart, slot.SlotEnd)) + } + lines = append(lines, "时段:"+strings.Join(slotParts, ";")) + } + if item.TaskClassID > 0 { + lines = append(lines, fmt.Sprintf("任务类 ID:%d", item.TaskClassID)) + } + return lines +} + +func resolveTaskQueueLabelByID(state *schedule.ScheduleState, taskID int) string { + if taskID <= 0 { + return "无" + } + if state == nil { + return fmt.Sprintf("[%d]任务", taskID) + } + task := state.TaskByStateID(taskID) + if task == nil { + return fmt.Sprintf("[%d]任务", taskID) + } + return fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")) +} + +func buildTaskDurationLabel(task *schedule.ScheduleTask) string { + if task == nil { + return "未标注" + } + if task.Duration > 0 { + return fmt.Sprintf("%d 节", task.Duration) + } + total := 0 + for _, slot := range task.Slots { + total += slot.SlotEnd - slot.SlotStart + 1 + } + if total <= 0 { + return "未标注" + } + return fmt.Sprintf("%d 节", total) +} + +func buildTaskDurationText(duration int) string { + if duration <= 0 { + return "未标注时长" + } + return fmt.Sprintf("%d 节连续时段", duration) +} + +func buildTaskPlacementLabel(task *schedule.ScheduleTask) string { + if task == nil || len(task.Slots) == 0 { + return "尚未落位" + } + if len(task.Slots) == 1 { + slot := task.Slots[0] + return fmt.Sprintf("1 段(第%d天 第%d-%d节)", slot.Day, slot.SlotStart, slot.SlotEnd) + } + return fmt.Sprintf("%d 段", len(task.Slots)) +} + +func buildTaskRelationLines(state *schedule.ScheduleState, task *schedule.ScheduleTask) []string { + if task == nil { + return nil + } + lines := make([]string, 0, 2) + if task.EmbeddedBy != nil { + lines = append(lines, "当前已嵌入任务:"+resolveTaskQueueLabelByID(state, *task.EmbeddedBy)) + } else if task.CanEmbed { + lines = append(lines, "当前没有嵌入其他任务。") + } + if task.EmbedHost != nil { + lines = append(lines, "嵌入宿主:"+resolveTaskQueueLabelByID(state, *task.EmbedHost)) + } + return lines +} diff --git a/backend/newAgent/tools/工具结果结构化交接文档.md b/backend/newAgent/tools/工具结果结构化交接文档.md new file mode 100644 index 0000000..b6a5be4 --- /dev/null +++ b/backend/newAgent/tools/工具结果结构化交接文档.md @@ -0,0 +1,258 @@ +# 工具结果结构化交接文档 + +## 当前状态 + +本轮已经完成第二批 read 事实域工具的后端结构化结果改造。外层协议仍然是 `ToolExecutionResult`,LLM 观察文本继续走 `ObservationText`,前端展示信息走 `ResultView` / `ArgumentView`。 + +已经直接切到 `result_view.view_type = "schedule.read_result"` 的工具: + +1. `query_available_slots` +2. `query_target_tasks` +3. `query_range` +4. `get_overview` +5. `get_task_info` +6. `queue_status` + +尚未进入本轮的工具继续走 `LegacyResult`,不要为了“统一外观”在本轮顺手迁移其它工具。 + +当前新增/修改文件: + +1. `backend/newAgent/tools/schedule_read_result_types.go` + - read 结果常量、payload 结构、轻量内部结构。 +2. `backend/newAgent/tools/schedule_read_result_common.go` + - 统一 `schedule.read_result` builder、失败卡片、中文格式化、跨工具统计 helper。 + - 当前文件偏重,约 600 行,是下一轮整理的重点。 +3. `backend/newAgent/tools/schedule_read_slots_handlers.go` + - `query_available_slots`、`query_range`。 +4. `backend/newAgent/tools/schedule_read_tasks_handlers.go` + - `query_target_tasks`、`get_task_info`。 +5. `backend/newAgent/tools/schedule_read_overview_queue_handlers.go` + - `get_overview`、`queue_status`。 +6. `backend/newAgent/tools/execution_result.go` + - 补齐 read/analyze 相关参数的中文 `argument_view` 标签和展示值。 +7. `backend/newAgent/tools/registry.go` + - 只把 6 个 read 工具的注册入口替换成新的 `NewXxxToolHandler()`。 + +## 第二批协议 + +第二批新增的前端 view type 只有一个: + +```json +{ + "view_type": "schedule.read_result", + "version": 1, + "collapsed": {}, + "expanded": {} +} +``` + +`collapsed` 面向卡片折叠态: + +```json +{ + "title": "找到 6 个目标任务", + "subtitle": "建议安排任务,已入队 6 个", + "status": "done", + "status_label": "已完成", + "metrics": [ + { "label": "任务数", "value": "6 个" } + ] +} +``` + +`expanded` 面向卡片展开态: + +```json +{ + "items": [ + { + "title": "[12]英语作文", + "subtitle": "待安排,2 节", + "tags": ["待安排", "学习"], + "detail_lines": ["位置:未安排"], + "meta": { + "task_id": 12 + } + } + ], + "sections": [ + { + "type": "items", + "title": "候选任务", + "items": [] + } + ], + "raw_text": "原始 observation 文本", + "machine_payload": {} +} +``` + +C 端默认展示字段: + +1. `collapsed.title` +2. `collapsed.subtitle` +3. `collapsed.status_label` +4. `collapsed.metrics[].label/value` +5. `expanded.items[].title/subtitle/tags/detail_lines` +6. `expanded.sections[].title/summary/items` + +默认不要展示的机器字段: + +1. `expanded.machine_payload` +2. `expanded.raw_text` +3. `items[].meta` +4. `task_id`、`day`、`slot_start`、`week_filter` 等机器参数名 + +这些字段只给调试、回传、后续交互使用。 + +## 整理任务 + +当前第二批代码虽然已经拆成多个文件,但仍然平铺在 `backend/newAgent/tools` 根包里。后续整理目标是把 read 结果构造逻辑收到子目录,避免根目录继续膨胀。 + +不要直接把现有 `.go` 文件机械移动到子目录。Go 里子目录就是新 package;当前文件依赖父包里的 `ToolHandler`、`ToolExecutionResult`、`ToolDisplayView`、`LegacyResultWithState` 等类型,直接移动会造成 import cycle。 + +推荐整理结构: + +```text +backend/newAgent/tools/ + registry.go + schedule_read_handlers.go + schedule_read/ + types.go + common.go + slots.go + tasks.go + overview_queue.go +``` + +职责边界: + +1. `backend/newAgent/tools/schedule_read/**` + - 子包只做纯 read 展示数据构造。 + - 不 import 父包 `newagenttools`。 + - 不返回 `ToolExecutionResult`。 + - 返回类似 `ReadResultView` 的纯数据结构:`ViewType`、`Collapsed`、`Expanded`、`MachinePayload`。 +2. `backend/newAgent/tools/schedule_read_handlers.go` + - 留在父包 `newagenttools`。 + - 只做薄 adapter:调用 `schedule_read` 子包构造展示数据,再包成 `ToolExecutionResult`。 + - 继续保证 `ObservationText` 原样给 LLM。 +3. `registry.go` + - 只保留注册入口,不放业务逻辑。 + +如果整理时发现 `schedule_read_result_common.go` 里的 helper 同时被第三批 analysis 使用,再考虑抽更中性的公共包: + +```text +backend/newAgent/tools/toolview/ +``` + +但不要提前大抽象;只有 read 和 analysis 都真实复用同一批结构后再抽。 + +## 第三批计划 + +第三批建议处理 schedule 诊断分析域: + +1. `analyze_health` +2. `analyze_rhythm` + +建议新增: + +```text +backend/newAgent/tools/schedule_analysis/ +``` + +建议新增 view type: + +```text +schedule.analysis_result +``` + +外层协议继续不变: + +```json +{ + "result_view": { + "view_type": "schedule.analysis_result", + "version": 1, + "collapsed": {}, + "expanded": {} + } +} +``` + +`schedule.analysis_result` 仍复用通用卡片结构,但语义上区别于 read: + +1. `collapsed.title` + - 例:`综合体检:建议继续微调`、`学习节律分析` +2. `collapsed.subtitle` + - 例:主问题、裁决摘要、风险摘要。 +3. `collapsed.metrics` + - 例:高认知相邻天数、可局部移动任务数、推荐动作。 +4. `expanded.sections` + - `裁决结论` + - `关键指标` + - `问题清单` + - `候选操作` + - `建议后续动作` +5. `expanded.items` + - 候选动作或风险日列表。 +6. `expanded.machine_payload` + - 原始 JSON、候选动作参数、required_reads 等机器字段,只给调试/交互。 + +第三批不要和 read 整理同时改同一个公共 helper 文件。推荐顺序: + +1. 先完成 read 整理,确定子包边界。 +2. 再做 `schedule_analysis` 子包。 +3. 最后只在父包 adapter 和 `registry.go` 接入 `analyze_health/analyze_rhythm`。 + +## 后续批次 + +第四批建议处理非 schedule read/analysis 主链: + +1. `web_search` +2. `web_fetch` +3. `upsert_task_class` +4. `context_tools_add` +5. `context_tools_remove` + +可考虑的 view type: + +1. `web.search_result` +2. `web.fetch_result` +3. `taskclass.write_result` +4. `tool.context_result` + +这些工具不建议混入 `schedule.read_result` 或 `schedule.analysis_result`,否则前端语义会越来越模糊。 + +## 前端补丁提示 + +第二批后端已经新增 `schedule.read_result`。前端最低要求: + +1. 头部继续优先读: + - `result_view.collapsed.title` + - `result_view.collapsed.subtitle` + - `result_view.collapsed.status_label` + - `result_view.collapsed.metrics` +2. 展开态新增通用 sections/items renderer: + - 支持 `expanded.items` + - 支持 `expanded.sections` + - section 类型至少兼容 `items`、`kv`、`callout` +3. 默认不展示: + - `expanded.machine_payload` + - `items[].meta` + - 原始英文 key/value +4. `raw_text` 只放 debug 折叠区。 + +前端如果暂时不识别 `schedule.read_result`,至少能显示折叠态;但展开态会退回 raw text,不符合 C 端目标。 + +## 验收清单 + +后续代理继续处理前必须先确认: + +1. 不回滚、覆盖、删除用户或其它代理的工作区改动。 +2. 不碰前端,除非用户明确要求。 +3. `ObservationText` 不能被展示层改写。 +4. 工具结果必须继续通过 `ToolExecutionResult -> SSE extra -> timeline payload` 传递。 +5. 每次跑 `go test` 后必须删除根目录 `.gocache`。 +6. 如果新增临时 `*_test.go`,跑完测试后必须删除。 +7. 结构迁移最终答复要说明:迁了什么、旧实现保留什么、切流点在哪里、下一轮建议迁什么。 +