diff --git a/backend/newAgent/node/deliver.go b/backend/newAgent/node/deliver.go index a361e27..6e0da3a 100644 --- a/backend/newAgent/node/deliver.go +++ b/backend/newAgent/node/deliver.go @@ -67,10 +67,18 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error { return fmt.Errorf("交付阶段状态推送失败: %w", err) } - // 2. 调 LLM 生成交付总结。 + // 2. 在线流式消息会把 execute / deliver 的正文追加到同一条 assistant 气泡。 + // 2.1 deliver 的 LLM 真流式路径不会经过 normalizeSpeak,因此第一段总结可能贴住上一段 execute 正文。 + // 2.2 这里先发一个仅用于 SSE 展示的段落分隔;不写入 history,避免历史回放和持久化消息额外多空行。 + // 2.3 若本轮 deliver 前没有任何正文,前端 Markdown 渲染会 trim 掉开头空行,不影响首段展示。 + if err := emitter.EmitAssistantText(deliverSpeakBlockID, deliverStageName, "\n\n", false); err != nil { + return fmt.Errorf("交付总结段落分隔推送失败: %w", err) + } + + // 3. 调 LLM 生成交付总结。 summary, streamed := generateDeliverSummary(ctx, input.Client, flowState, conversationContext, input.ThinkingEnabled, input.CompactionStore, emitter) - // 2.1 排程完毕卡片信号: + // 3.1 排程完毕卡片信号: // 1. 仅在流程正常完成且确实产生过日程变更(粗排或写工具)时推送; // 2. 前端收到 kind=schedule_completed 后,自行用对话 ID 调用现有接口拉取排程数据渲染卡片; // 3. 不携带 Redis key 或排程数据,保持信号职责单一。 @@ -78,7 +86,7 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error { _ = emitter.EmitScheduleCompleted(deliverStatusBlockID, deliverStageName) } - // 3. 推送总结。LLM 路径已在 generateDeliverSummary 内部真流式推送, + // 4. 推送总结。LLM 路径已在 generateDeliverSummary 内部真流式推送, // 仅机械/降级路径需要在此伪流式补推。 if strings.TrimSpace(summary) != "" { if !streamed { @@ -101,7 +109,7 @@ func RunDeliverNode(ctx context.Context, input DeliverNodeInput) error { } } - // 4. 推送最终完成状态。 + // 5. 推送最终完成状态。 _ = emitter.EmitStatus( deliverStatusBlockID, deliverStageName, diff --git a/backend/newAgent/tools/context_tools.go b/backend/newAgent/tools/context_tools.go index fb57018..c7e18ba 100644 --- a/backend/newAgent/tools/context_tools.go +++ b/backend/newAgent/tools/context_tools.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + toolcontextresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/tool_context_result" ) type contextToolsAddResult struct { @@ -34,22 +35,22 @@ type contextToolsRemoveResult struct { // NewContextToolsAddHandler 创建 context_tools_add 工具。 // // 职责边界: -// 1. 仅负责校验 domain/mode/packs 并返回结构化结果,不直接修改流程状态; -// 2. 真正的“激活态写回”由 execute 节点根据工具结果回写 CommonState; -// 3. schedule 支持可选 packs,taskclass 当前不支持可选 packs。 +// 1. 这里只负责校验 domain / packs / mode,并产出结构化结果; +// 2. 不直接改 CommonState,真正的激活切流仍由 execute 层读取 observation 后更新快照; +// 3. 因为这里拿不到 CommonState,所以卡片展示的是“本次工具结果返回的 domain/packs/mode”,不是全局最终快照。 func NewContextToolsAddHandler() ToolHandler { return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { _ = state domain := NormalizeToolDomain(readContextToolString(args["domain"])) if domain == "" { - return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{ + return buildContextToolsAddExecutionResult(args, contextToolsAddResult{ Tool: ToolNameContextToolsAdd, Success: false, Action: "reject", Error: "参数非法:domain 仅支持 schedule/taskclass", ErrorCode: "invalid_domain", - })) + }) } mode := strings.ToLower(strings.TrimSpace(readContextToolString(args["mode"]))) @@ -57,52 +58,54 @@ func NewContextToolsAddHandler() ToolHandler { mode = "replace" } if mode != "replace" && mode != "merge" { - return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{ + return buildContextToolsAddExecutionResult(args, contextToolsAddResult{ Tool: ToolNameContextToolsAdd, Success: false, Action: "reject", Domain: domain, Error: "参数非法:mode 仅支持 replace/merge", ErrorCode: "invalid_mode", - })) + }) } packsRaw := readContextToolStringSlice(args["packs"]) packs, errCode, errText := validateContextPacks(domain, packsRaw, false) if errCode != "" { - return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{ + return buildContextToolsAddExecutionResult(args, contextToolsAddResult{ Tool: ToolNameContextToolsAdd, Success: false, Action: "reject", Domain: domain, Error: errText, ErrorCode: errCode, - })) + }) } - // schedule 未显式传 packs 时,默认启用最小可用包(mutation + analyze)。 + // 1. schedule 未显式传 packs 时,默认激活最小可用包 mutation+analyze。 + // 2. taskclass 当前没有可选包,所以这里会保持空切片,由 execute 层只保留固定 core。 + // 3. 这样做可以让 observation 直接表达“本次实际生效的可选包集合”,减少展示层再二次猜测。 if domain == ToolDomainSchedule && len(packsRaw) == 0 { packs = ResolveEffectiveToolPacks(domain, nil) } - return LegacyResult(ToolNameContextToolsAdd, args, marshalContextToolsAddResult(contextToolsAddResult{ + return buildContextToolsAddExecutionResult(args, contextToolsAddResult{ Tool: ToolNameContextToolsAdd, Success: true, Action: "activate", Domain: domain, Packs: packs, Mode: mode, - Message: "已激活工具域,可继续调用对应业务工具。", - })) + Message: "已激活目标工具域,可继续调用对应业务工具。", + }) } } // NewContextToolsRemoveHandler 创建 context_tools_remove 工具。 // // 职责边界: -// 1. 仅解析 domain/all/packs 语义并返回结构化结果,不直接触碰上下文存储; -// 2. all=true 表示清空动态区业务工具,domain+packs 表示移除该域下指定二级包; -// 3. 仅 schedule 支持按 packs 移除,且 core 不允许显式移除。 +// 1. 这里只解释 domain / packs / all 的语义,并返回结构化结果; +// 2. all=true 表示清空全部业务工具域,domain+packs 表示移除某域下的可选包; +// 3. 实际 CommonState 的域/包更新,仍由 execute 层统一消费 observation 完成。 func NewContextToolsRemoveHandler() ToolHandler { return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { _ = state @@ -111,61 +114,160 @@ func NewContextToolsRemoveHandler() ToolHandler { domainRaw := strings.ToLower(strings.TrimSpace(readContextToolString(args["domain"]))) packsRaw := readContextToolStringSlice(args["packs"]) - // 兼容写法:domain=all 视为清空全部。 + // 兼容旧写法:domain=all 也视为清空全部业务工具域。 if domainRaw == "all" { all = true } if all { - return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: true, Action: "clear_all", All: true, - Message: "已移除全部业务工具域,仅保留上下文管理工具。", - })) + Message: "已移除全部业务工具域,仅保留 context 管理工具。", + }) } domain := NormalizeToolDomain(domainRaw) if domain == "" { - return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: false, Action: "reject", - Error: "参数非法:需提供 domain=schedule/taskclass 或 all=true", + Error: "参数非法:需要提供 domain=schedule/taskclass 或 all=true", ErrorCode: "invalid_domain", - })) + }) } packs, errCode, errText := validateContextPacks(domain, packsRaw, true) if errCode != "" { - return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: false, Action: "reject", Domain: domain, Error: errText, ErrorCode: errCode, - })) + }) } if len(packs) > 0 { - return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: true, Action: "deactivate_packs", Domain: domain, Packs: packs, Message: "已移除指定工具包。", - })) + }) } - return LegacyResult(ToolNameContextToolsRemove, args, marshalContextToolsRemoveResult(contextToolsRemoveResult{ + return buildContextToolsRemoveExecutionResult(args, contextToolsRemoveResult{ Tool: ToolNameContextToolsRemove, Success: true, Action: "deactivate", Domain: domain, Message: "已移除指定工具域。", - })) + }) + } +} + +func buildContextToolsAddExecutionResult(args map[string]any, payload contextToolsAddResult) ToolExecutionResult { + observation := marshalContextToolsAddResult(payload) + legacy := LegacyResult(ToolNameContextToolsAdd, args, observation) + view := toolcontextresult.BuildAddView(toContextToolsAddPayload(payload), observation) + return buildContextToolExecutionResult(legacy, args, view) +} + +func buildContextToolsRemoveExecutionResult(args map[string]any, payload contextToolsRemoveResult) ToolExecutionResult { + observation := marshalContextToolsRemoveResult(payload) + legacy := LegacyResult(ToolNameContextToolsRemove, args, observation) + view := toolcontextresult.BuildRemoveView(toContextToolsRemovePayload(payload), observation) + return buildContextToolExecutionResult(legacy, args, view) +} + +// buildContextToolExecutionResult 负责把子包纯展示结构包回 ToolExecutionResult。 +// +// 职责边界: +// 1. 只做 ContextResultView -> ToolDisplayView 的协议桥接; +// 2. 不改写 ObservationText,确保模型侧仍消费原始 observation JSON; +// 3. 错误码与错误文案继续复用父包现有 JSON/text 解析逻辑,避免多套失败判定分叉。 +func buildContextToolExecutionResult( + legacy ToolExecutionResult, + args map[string]any, + view toolcontextresult.ContextResultView, +) ToolExecutionResult { + result := legacy + status := normalizeToolStatus(result.Status) + if status == "" { + status = ToolStatusDone + } + + collapsed := cloneAnyMap(view.Collapsed) + if collapsed == nil { + collapsed = make(map[string]any) + } + expanded := cloneAnyMap(view.Expanded) + if expanded == nil { + expanded = make(map[string]any) + } + + collapsed["status"] = status + if _, exists := collapsed["status_label"]; !exists { + collapsed["status_label"] = resolveToolStatusLabelCN(status) + } + if _, exists := expanded["raw_text"]; !exists { + expanded["raw_text"] = result.ObservationText + } + + result.Status = status + result.Success = status == ToolStatusDone + result.ResultView = &ToolDisplayView{ + ViewType: strings.TrimSpace(view.ViewType), + Version: view.Version, + Collapsed: collapsed, + Expanded: expanded, + } + if title, ok := readStringAnyMap(collapsed, "title"); ok { + result.Summary = title + } + if !result.Success { + errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status) + if strings.TrimSpace(result.ErrorCode) == "" { + result.ErrorCode = strings.TrimSpace(errorCode) + } + if strings.TrimSpace(result.ErrorMessage) == "" { + result.ErrorMessage = strings.TrimSpace(errorMessage) + } + } + return EnsureToolResultDefaults(result, args) +} + +func toContextToolsAddPayload(payload contextToolsAddResult) toolcontextresult.ContextToolsAddPayload { + return toolcontextresult.ContextToolsAddPayload{ + Tool: payload.Tool, + Success: payload.Success, + Action: payload.Action, + Domain: payload.Domain, + Packs: append([]string(nil), payload.Packs...), + Mode: payload.Mode, + Message: payload.Message, + Error: payload.Error, + ErrorCode: payload.ErrorCode, + } +} + +func toContextToolsRemovePayload(payload contextToolsRemoveResult) toolcontextresult.ContextToolsRemovePayload { + return toolcontextresult.ContextToolsRemovePayload{ + Tool: payload.Tool, + Success: payload.Success, + Action: payload.Action, + Domain: payload.Domain, + Packs: append([]string(nil), payload.Packs...), + All: payload.All, + Message: payload.Message, + Error: payload.Error, + ErrorCode: payload.ErrorCode, } } diff --git a/backend/newAgent/tools/execution_result.go b/backend/newAgent/tools/execution_result.go index c710b63..59fcbef 100644 --- a/backend/newAgent/tools/execution_result.go +++ b/backend/newAgent/tools/execution_result.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + toolcontextresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/tool_context_result" ) const ( @@ -473,6 +474,8 @@ func argumentDisplayRank(key string) int { switch strings.TrimSpace(key) { case "task_id", "task_ids", "task_item_id", "task_item_ids", "task_a", "task_b": return 10 + case "domain", "packs", "mode", "all": + return 15 case "status", "category": return 20 case "day", "new_day", "day_start", "day_end", "day_scope", "day_of_week": @@ -585,6 +588,14 @@ func resolveArgumentLabelCN(key string) string { return "移动列表" case "reason": return "原因" + case "domain": + return "工具域" + case "packs": + return "工具包" + case "mode": + return "注入模式" + case "all": + return "清空全部" case "status": return "状态" case "category": @@ -692,6 +703,35 @@ func formatArgumentDisplay( if enabled, ok := toBool(value); ok { return formatBoolLabelCN(enabled) } + case "domain": + if text, ok := value.(string); ok { + return fallbackText(toolcontextresult.ResolveDomainLabelCN(text), text) + } + case "packs": + switch typed := value.(type) { + case []string: + return toolcontextresult.FormatPacksCN(typed) + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + text := strings.TrimSpace(fmt.Sprintf("%v", item)) + if text == "" || text == "" { + continue + } + items = append(items, text) + } + if len(items) > 0 { + return toolcontextresult.FormatPacksCN(items) + } + case string: + if strings.TrimSpace(typed) != "" { + return toolcontextresult.FormatPacksCN(strings.Split(strings.TrimSpace(typed), ",")) + } + } + case "mode": + if text, ok := value.(string); ok { + return fallbackText(toolcontextresult.ResolveModeLabelCN(text), text) + } case "status": if text, ok := value.(string); ok { return formatTargetPoolStatusCN(text) diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go index 35e2ee3..5c55e57 100644 --- a/backend/newAgent/tools/registry.go +++ b/backend/newAgent/tools/registry.go @@ -330,9 +330,7 @@ func registerScheduleReadTools(r *ToolRegistry) { "queue_pop_head", "弹出并返回当前队首任务;若已有 current 则复用。", `{"name":"queue_pop_head","parameters":{}}`, - wrapLegacyToolHandler("queue_pop_head", func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.QueuePopHead(state, args) - }), + NewQueuePopHeadToolHandler(), ) r.Register( "queue_status", @@ -353,17 +351,13 @@ func registerScheduleAnalyzeTools(r *ToolRegistry) { "analyze_rhythm", "分析学习节奏与切换情况。", `{"name":"analyze_rhythm","parameters":{"category":{"type":"string"},"include_pending":{"type":"bool"},"detail":{"type":"string","enum":["summary","full"]},"hard_categories":{"type":"array","items":{"type":"string"}}}}`, - wrapLegacyToolHandler("analyze_rhythm", func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.AnalyzeRhythm(state, args) - }), + NewAnalyzeRhythmToolHandler(), ) r.Register( "analyze_health", "主动优化裁判入口:聚焦 rhythm/semantic_profile/tightness,判断当前是否还值得继续优化,并给出候选。", `{"name":"analyze_health","parameters":{"detail":{"type":"string","enum":["summary","full"]},"dimensions":{"type":"array","items":{"type":"string"}},"threshold":{"type":"string","enum":["strict","normal","relaxed"]}}}`, - wrapLegacyToolHandler("analyze_health", func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.AnalyzeHealth(state, args) - }), + NewAnalyzeHealthToolHandler(), ) } @@ -402,9 +396,7 @@ func registerScheduleMutationTools(r *ToolRegistry) { "queue_skip_head", "跳过当前队首任务,将其标记为 skipped。", `{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`, - wrapLegacyToolHandler("queue_skip_head", func(state *schedule.ScheduleState, args map[string]any) string { - return schedule.QueueSkipHead(state, args) - }), + NewQueueSkipHeadToolHandler(), ) r.Register( "unplace", @@ -424,31 +416,16 @@ func registerTaskClassTools(r *ToolRegistry, deps DefaultRegistryDeps) { } func registerWebTools(r *ToolRegistry, deps DefaultRegistryDeps) { - webSearchHandler := web.NewSearchToolHandler(deps.WebSearchProvider) - webFetchHandler := web.NewFetchToolHandler(web.NewFetcher()) - r.Register( "web_search", "Web 搜索:根据 query 返回结构化检索结果。query 必填。", `{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`, - wrapLegacyToolHandler("web_search", func(state *schedule.ScheduleState, args map[string]any) string { - _ = state - return webSearchHandler.Handle(args) - }), + NewWebSearchToolHandler(deps.WebSearchProvider), ) r.Register( "web_fetch", "抓取指定 URL 的正文内容并做最小清洗。url 必填。", `{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`, - wrapLegacyToolHandler("web_fetch", func(state *schedule.ScheduleState, args map[string]any) string { - _ = state - return webFetchHandler.Handle(args) - }), + NewWebFetchToolHandler(web.NewFetcher()), ) } - -func wrapLegacyToolHandler(toolName string, handler func(state *schedule.ScheduleState, args map[string]any) string) ToolHandler { - return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { - return LegacyResultWithState(toolName, args, state, handler(state, args)) - } -} diff --git a/backend/newAgent/tools/schedule_analysis/common.go b/backend/newAgent/tools/schedule_analysis/common.go new file mode 100644 index 0000000..b4cd098 --- /dev/null +++ b/backend/newAgent/tools/schedule_analysis/common.go @@ -0,0 +1,517 @@ +package schedule_analysis + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// BuildResultView 统一封装 schedule.analysis_result 结构。 +// +// 职责边界: +// 1. 负责把已经计算好的折叠态/展开态内容组装成标准视图; +// 2. 负责在子包内补齐 status / status_label,避免依赖父包常量; +// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。 +func BuildResultView(input BuildResultViewInput) AnalysisResultView { + status := normalizeStatus(input.Status) + if status == "" { + status = StatusDone + } + + collapsed := map[string]any{ + "title": strings.TrimSpace(input.Title), + "subtitle": strings.TrimSpace(input.Subtitle), + "status": status, + "status_label": resolveStatusLabelCN(status), + "metrics": metricListToMaps(input.Metrics), + } + expanded := map[string]any{ + "items": itemListToMaps(input.Items), + "sections": cloneSectionList(input.Sections), + "raw_text": input.Observation, + } + if len(input.MachinePayload) > 0 { + expanded["machine_payload"] = cloneAnyMap(input.MachinePayload) + } + + return AnalysisResultView{ + ViewType: ViewTypeAnalysisResult, + Version: ViewVersionAnalysisResult, + Collapsed: collapsed, + Expanded: expanded, + } +} + +// BuildFailureView 统一生成 analysis 工具失败卡片视图。 +// +// 职责边界: +// 1. 只从 observation 中提炼失败文案和参数回显; +// 2. 不负责判断失败条件,调用方需要先确认 observation 失败; +// 3. raw_text 仍保留原始 observation,方便 debug 与下游排查。 +func BuildFailureView(input BuildFailureViewInput) AnalysisResultView { + status := normalizeStatus(input.Status) + if status == "" { + status = StatusFailed + } + title := strings.TrimSpace(input.Title) + if title == "" { + title = fmt.Sprintf("%s失败", resolveToolLabelCN(input.ToolName)) + } + subtitle := strings.TrimSpace(input.Subtitle) + if subtitle == "" { + subtitle = failureText(input.Observation, "诊断分析失败,请检查当前日程状态后重试。") + } + + sections := []map[string]any{ + BuildCalloutSection("执行失败", subtitle, "danger", []string{subtitle}), + } + appendSectionIfPresent(§ions, BuildArgsSection("分析参数", input.ArgFields)) + return BuildResultView(BuildResultViewInput{ + Status: status, + Title: title, + Subtitle: subtitle, + Sections: sections, + Observation: input.Observation, + }) +} + +func BuildMetric(label string, value string) MetricField { + return MetricField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)} +} + +func BuildKVField(label string, value string) KVField { + return KVField{Label: strings.TrimSpace(label), Value: strings.TrimSpace(value)} +} + +func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView { + return ItemView{ + Title: strings.TrimSpace(title), + Subtitle: strings.TrimSpace(subtitle), + Tags: normalizeStringSlice(tags), + DetailLines: normalizeStringSlice(detailLines), + Meta: cloneAnyMap(meta), + } +} + +func BuildItemsSection(title string, items []ItemView) map[string]any { + return map[string]any{ + "type": "items", + "title": strings.TrimSpace(title), + "items": itemListToMaps(items), + } +} + +func BuildKVSection(title string, fields []KVField) map[string]any { + rows := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + label := strings.TrimSpace(field.Label) + value := strings.TrimSpace(field.Value) + if label == "" || value == "" { + continue + } + rows = append(rows, map[string]any{ + "label": label, + "value": value, + }) + } + return map[string]any{ + "type": "kv", + "title": strings.TrimSpace(title), + "fields": rows, + } +} + +func BuildCalloutSection(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), + } +} + +// BuildArgsSection 把父包已经本地化的参数字段拼成展示 section。 +// +// 职责边界: +// 1. 只接受纯 KVField,不依赖父包 ToolArgumentView; +// 2. 不解释 detail / threshold / hard_categories 是否真实参与计算; +// 3. 没有有效字段时返回 nil,避免空 section 干扰前端。 +func BuildArgsSection(title string, fields []KVField) map[string]any { + if len(fields) == 0 { + return nil + } + valid := make([]KVField, 0, len(fields)) + for _, field := range fields { + if strings.TrimSpace(field.Label) == "" || strings.TrimSpace(field.Value) == "" { + continue + } + valid = append(valid, BuildKVField(field.Label, field.Value)) + } + if len(valid) == 0 { + return nil + } + return BuildKVSection(title, valid) +} + +func parseObservationJSON(observation string) (map[string]any, bool) { + trimmed := strings.TrimSpace(observation) + if trimmed == "" || !strings.HasPrefix(trimmed, "{") { + return nil, false + } + var payload map[string]any + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return nil, false + } + return payload, true +} + +func isSuccessPayload(payload map[string]any) bool { + success, ok := readBool(payload, "success") + return ok && success +} + +func failureText(observation string, fallback string) string { + if payload, ok := parseObservationJSON(observation); ok { + if message := firstString(payload, "error", "message", "reason", "err"); message != "" { + return message + } + } + if strings.TrimSpace(observation) != "" { + return strings.TrimSpace(observation) + } + return strings.TrimSpace(fallback) +} + +func normalizeStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case StatusDone: + return StatusDone + case StatusBlocked: + return StatusBlocked + case StatusFailed: + return StatusFailed + default: + return "" + } +} + +func resolveStatusLabelCN(status string) string { + switch normalizeStatus(status) { + case StatusDone: + return "已完成" + case StatusBlocked: + return "已阻断" + default: + return "失败" + } +} + +func resolveToolLabelCN(toolName string) string { + switch strings.TrimSpace(toolName) { + case "analyze_health": + return "综合体检" + case "analyze_rhythm": + return "学习节律分析" + default: + return "诊断分析" + } +} + +func appendSectionIfPresent(target *[]map[string]any, section map[string]any) { + if section == nil { + return + } + *target = append(*target, section) +} + +func metricListToMaps(metrics []MetricField) []map[string]any { + if len(metrics) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(metrics)) + for _, metric := range metrics { + label := strings.TrimSpace(metric.Label) + value := strings.TrimSpace(metric.Value) + if label == "" || value == "" { + continue + } + out = append(out, map[string]any{"label": label, "value": value}) + } + if len(out) == 0 { + return make([]map[string]any, 0) + } + return out +} + +func itemListToMaps(items []ItemView) []map[string]any { + if len(items) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + row := map[string]any{ + "title": strings.TrimSpace(item.Title), + "subtitle": strings.TrimSpace(item.Subtitle), + "tags": normalizeStringSlice(item.Tags), + "detail_lines": normalizeStringSlice(item.DetailLines), + } + if len(item.Meta) > 0 { + row["meta"] = cloneAnyMap(item.Meta) + } + out = append(out, row) + } + return out +} + +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 cloneSectionList(sections []map[string]any) []map[string]any { + if len(sections) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(sections)) + for _, section := range sections { + out = append(out, cloneAnyMap(section)) + } + return out +} + +func cloneAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = cloneAnyValue(value) + } + return out +} + +func cloneAnyValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneAnyMap(typed) + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneAnyValue(item)) + } + return out + case []map[string]any: + out := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneAnyMap(item)) + } + return out + case []string: + out := make([]string, len(typed)) + copy(out, typed) + return out + default: + return typed + } +} + +func readMap(input map[string]any, key string) map[string]any { + if len(input) == 0 { + return nil + } + value, ok := input[key] + if !ok { + return nil + } + row, _ := value.(map[string]any) + return row +} + +func readList(input map[string]any, key string) []any { + if len(input) == 0 { + return nil + } + value, ok := input[key] + if !ok { + return nil + } + rows, _ := value.([]any) + return rows +} + +func readString(input map[string]any, key string) string { + if len(input) == 0 { + return "" + } + value, ok := input[key] + if !ok || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + text := strings.TrimSpace(fmt.Sprintf("%v", typed)) + if text == "" { + return "" + } + return text + } +} + +func firstString(input map[string]any, keys ...string) string { + for _, key := range keys { + if value := readString(input, key); value != "" { + return value + } + } + return "" +} + +func readBool(input map[string]any, key string) (bool, bool) { + if len(input) == 0 { + return false, false + } + value, ok := input[key] + if !ok { + return false, false + } + typed, ok := value.(bool) + return typed, ok +} + +func readInt(input map[string]any, key string) int { + value := readFloat(input, key) + return int(value) +} + +func readFloat(input map[string]any, key string) float64 { + if len(input) == 0 { + return 0 + } + value, ok := input[key] + if !ok || value == nil { + return 0 + } + switch typed := value.(type) { + case float64: + return typed + case float32: + return float64(typed) + case int: + return float64(typed) + case int64: + return float64(typed) + default: + return 0 + } +} + +func severityRank(severity string) int { + switch strings.ToLower(strings.TrimSpace(severity)) { + case "critical": + return 0 + case "warning": + return 1 + default: + return 2 + } +} + +func formatSeverityCN(severity string) string { + switch strings.ToLower(strings.TrimSpace(severity)) { + case "critical": + return "高风险" + case "warning": + return "需关注" + default: + return "提示" + } +} + +func formatBoolCN(value bool) string { + if value { + return "是" + } + return "否" +} + +func formatFloat(value float64) string { + return fmt.Sprintf("%.2f", value) +} + +func formatPercent(value float64) string { + return fmt.Sprintf("%.0f%%", value*100) +} + +func formatOperationCN(operation string) string { + switch strings.TrimSpace(operation) { + case "move": + return "移动" + case "swap": + return "交换" + case "close": + return "收口" + case "ask_user": + return "询问用户" + default: + if strings.TrimSpace(operation) == "" { + return "未指定" + } + return strings.TrimSpace(operation) + } +} + +func formatEffectCN(effect string) string { + switch strings.TrimSpace(effect) { + case "improve": + return "明显改善" + case "partial_improve": + return "部分改善" + case "shift": + return "问题转移" + case "no_gain": + return "收益不足" + case "regress": + return "变差" + case "close": + return "收口" + default: + if strings.TrimSpace(effect) == "" { + return "未标注" + } + return strings.TrimSpace(effect) + } +} + +func sortedKeys(input map[string]any) []string { + keys := make([]string, 0, len(input)) + for key := range input { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func compactJSON(value any) string { + raw, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(raw) +} diff --git a/backend/newAgent/tools/schedule_analysis/health.go b/backend/newAgent/tools/schedule_analysis/health.go new file mode 100644 index 0000000..6b4c5de --- /dev/null +++ b/backend/newAgent/tools/schedule_analysis/health.go @@ -0,0 +1,267 @@ +package schedule_analysis + +import ( + "fmt" + "strings" +) + +// BuildAnalyzeHealthView 把 analyze_health 的原始 JSON observation 转成诊断卡片。 +// +// 步骤化说明: +// 1. 只解析 observation 的现有 JSON 字段,不改变字段名、层级或内容; +// 2. 展示层优先读取 feasibility / decision / metrics,避免依赖自然语言摘要; +// 3. 解析失败或 success=false 时返回失败卡片,raw_text 仍保留原始 observation。 +func BuildAnalyzeHealthView(input AnalyzeHealthViewInput) AnalysisResultView { + payload, ok := parseObservationJSON(input.Observation) + if !ok || !isSuccessPayload(payload) { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "analyze_health", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + metricsMap := readMap(payload, "metrics") + rhythm := readMap(metricsMap, "rhythm") + tightness := readMap(metricsMap, "tightness") + profile := readMap(metricsMap, "profile") + feasibility := readMap(payload, "feasibility") + decision := readMap(payload, "decision") + + title := buildHealthTitle(feasibility, decision) + subtitle := buildHealthSubtitle(feasibility, decision) + metrics := buildHealthMetrics(rhythm, tightness, profile, feasibility) + candidateItems := buildHealthCandidateItems(decision) + issueItems := buildIssueItems(readList(payload, "issues")) + + sections := []map[string]any{ + BuildKVSection("裁决结论", buildHealthDecisionFields(feasibility, decision, metricsMap)), + BuildKVSection("关键指标", buildHealthMetricFields(rhythm, tightness, profile, metricsMap)), + } + if len(issueItems) > 0 { + sections = append(sections, BuildItemsSection("问题清单", issueItems)) + } else { + sections = append(sections, BuildCalloutSection("问题清单", "当前没有结构化问题项。", "info", nil)) + } + if len(candidateItems) > 0 { + sections = append(sections, BuildItemsSection("候选操作", candidateItems)) + } else { + sections = append(sections, BuildCalloutSection("候选操作", "当前没有可执行候选。", "info", nil)) + } + sections = append(sections, buildHealthNextStepSection(feasibility, decision, candidateItems)) + appendSectionIfPresent(§ions, BuildArgsSection("分析参数", input.ArgFields)) + + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: title, + Subtitle: subtitle, + Metrics: metrics, + Items: candidateItems, + Sections: sections, + Observation: input.Observation, + MachinePayload: payload, + }) +} + +func buildHealthTitle(feasibility map[string]any, decision map[string]any) string { + if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible { + return "综合体检:当前约束不可行" + } + if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue { + return "综合体检:建议继续微调" + } + return "综合体检:可以收口" +} + +func buildHealthSubtitle(feasibility map[string]any, decision map[string]any) string { + if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible { + gap := readInt(feasibility, "capacity_gap") + reason := readString(feasibility, "reason_code") + if reason == "" { + reason = "capacity_insufficient" + } + return fmt.Sprintf("容量仍缺 %d 节,原因:%s。", gap, reason) + } + if problem := readString(decision, "primary_problem"); problem != "" { + return problem + } + return "当前没有发现需要继续处理的结构化问题。" +} + +func buildHealthMetrics(rhythm, tightness, profile, feasibility map[string]any) []MetricField { + metrics := []MetricField{ + BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(rhythm, "heavy_adjacent_days"))), + BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(rhythm, "max_switch_count"))), + BuildMetric("可局部移动", fmt.Sprintf("%d 项", readInt(tightness, "locally_movable_task_count"))), + BuildMetric("紧度", fallbackLabel(readString(tightness, "tightness_level"), "未标注")), + } + if gap := readInt(feasibility, "capacity_gap"); gap > 0 { + metrics = append(metrics, BuildMetric("容量缺口", fmt.Sprintf("%d 节", gap))) + return metrics + } + if missing := readInt(profile, "missing_complete_profile_count"); missing > 0 { + metrics = append(metrics, BuildMetric("画像缺失", fmt.Sprintf("%d 门", missing))) + } + return metrics +} + +func buildHealthDecisionFields(feasibility map[string]any, decision map[string]any, metrics map[string]any) []KVField { + shouldContinue, _ := readBool(decision, "should_continue_optimize") + forced, _ := readBool(decision, "is_forced_imperfection") + canClose, _ := readBool(metrics, "can_close") + feasible, feasibleOK := readBool(feasibility, "is_feasible") + feasibleText := "未返回" + if feasibleOK { + feasibleText = formatBoolCN(feasible) + } + + return []KVField{ + BuildKVField("是否继续优化", formatBoolCN(shouldContinue)), + BuildKVField("当前可收口", formatBoolCN(canClose)), + BuildKVField("推荐动作", formatOperationCN(readString(decision, "recommended_operation"))), + BuildKVField("主问题", fallbackLabel(readString(decision, "primary_problem"), "当前没有发现值得继续处理的局部认知问题")), + BuildKVField("约束代价", formatBoolCN(forced)), + BuildKVField("约束可行", feasibleText), + BuildKVField("容量缺口", fmt.Sprintf("%d 节", readInt(feasibility, "capacity_gap"))), + BuildKVField("可行性原因", fallbackLabel(readString(feasibility, "reason_code"), "未返回")), + } +} + +func buildHealthMetricFields(rhythm, tightness, profile, metrics map[string]any) []KVField { + canClose, _ := readBool(metrics, "can_close") + return []KVField{ + BuildKVField("认知块平衡", fmt.Sprintf("%d", readInt(rhythm, "block_balance"))), + BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(rhythm, "fragmented_count"))), + BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(rhythm, "compressed_run_count"))), + BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(rhythm, "avg_switches_per_day"))), + BuildKVField("同类型切换占比", formatPercent(readFloat(rhythm, "same_type_transition_ratio"))), + BuildKVField("局部候选均值", fmt.Sprintf("%.1f 个", readFloat(tightness, "avg_local_alternative_slots"))), + BuildKVField("跨任务类交换机会", fmt.Sprintf("%d 个", readInt(tightness, "cross_class_swap_options"))), + BuildKVField("被迫高认知相邻", fmt.Sprintf("%d 天", readInt(tightness, "forced_heavy_adjacent_days"))), + BuildKVField("语义画像缺失", fmt.Sprintf("%d 门", readInt(profile, "missing_complete_profile_count"))), + BuildKVField("当前可收口", formatBoolCN(canClose)), + } +} + +func buildHealthCandidateItems(decision map[string]any) []ItemView { + candidates := readList(decision, "candidates") + if len(candidates) == 0 { + return make([]ItemView, 0) + } + items := make([]ItemView, 0, len(candidates)) + for _, raw := range candidates { + candidate, ok := raw.(map[string]any) + if !ok { + continue + } + after := readMap(candidate, "after") + canClose, _ := readBool(after, "can_close") + tool := readString(candidate, "tool") + effect := readString(candidate, "effect") + title := readString(candidate, "summary") + if title == "" { + title = fallbackLabel(readString(candidate, "candidate_id"), "候选操作") + } + subtitle := readString(after, "primary_problem") + if subtitle == "" { + subtitle = fmt.Sprintf("效果:%s", formatEffectCN(effect)) + } + + tags := []string{formatOperationCN(tool), formatEffectCN(effect)} + if canClose { + tags = append(tags, "执行后可收口") + } + detailLines := []string{ + "候选 ID:" + fallbackLabel(readString(candidate, "candidate_id"), "未返回"), + "参数:" + compactJSON(candidate["arguments"]), + fmt.Sprintf("执行后高认知相邻:%d 天", readInt(after, "heavy_adjacent_days")), + fmt.Sprintf("执行后最大切换:%d 次", readInt(after, "max_switch_count")), + "执行后同类型切换占比:" + formatPercent(readFloat(after, "same_type_transition_ratio")), + } + items = append(items, BuildItem(title, subtitle, tags, detailLines, candidate)) + } + return items +} + +func buildIssueItems(rows []any) []ItemView { + if len(rows) == 0 { + return make([]ItemView, 0) + } + items := make([]ItemView, 0, len(rows)) + for _, raw := range rows { + issue, ok := raw.(map[string]any) + if !ok { + continue + } + trigger := readMap(issue, "trigger") + severity := readString(issue, "severity") + dimension := readString(issue, "dimension") + title := describeIssue(issue) + detailLines := make([]string, 0, 3) + if metric := readString(trigger, "metric"); metric != "" { + detailLines = append(detailLines, fmt.Sprintf("触发指标:%s %s %.2f,实际 %.2f", metric, readString(trigger, "operator"), readFloat(trigger, "threshold"), readFloat(trigger, "actual"))) + } + detailLines = append(detailLines, "问题 ID:"+fallbackLabel(readString(issue, "issue_id"), "未返回")) + items = append(items, BuildItem( + title, + fmt.Sprintf("%s,%s", fallbackLabel(dimension, "未标注维度"), formatSeverityCN(severity)), + []string{formatSeverityCN(severity), fallbackLabel(dimension, "未标注维度")}, + detailLines, + issue, + )) + } + return items +} + +func buildHealthNextStepSection(feasibility map[string]any, decision map[string]any, candidateItems []ItemView) map[string]any { + if feasible, ok := readBool(feasibility, "is_feasible"); ok && !feasible { + return BuildCalloutSection( + "建议后续动作", + "当前先不要继续写操作,应先与用户协商时间窗、约束或任务范围。", + "warning", + []string{"可选方向:扩展时间窗、放宽排除约束、缩减任务量,或确认接受风险收口。"}, + ) + } + if shouldContinue, ok := readBool(decision, "should_continue_optimize"); ok && shouldContinue { + return BuildCalloutSection( + "建议后续动作", + "优先从候选操作里选择收益明确的一项执行。", + "info", + []string{fmt.Sprintf("当前共有 %d 个候选项;执行后建议再次调用 analyze_health 复诊。", len(candidateItems))}, + ) + } + return BuildCalloutSection( + "建议后续动作", + "当前可以收口;如用户仍要求微调,再按具体偏好追加读取或局部调整。", + "info", + nil, + ) +} + +func describeIssue(issue map[string]any) string { + issueID := readString(issue, "issue_id") + dimension := readString(issue, "dimension") + switch { + case strings.Contains(issueID, "feasibility"): + return "容量可行性不足" + case strings.Contains(issueID, "semantic_profile"): + return "任务类语义画像不完整" + case strings.Contains(issueID, "heavy_adjacent"): + return "存在高认知任务相邻" + case strings.Contains(issueID, "switch"): + return "单日任务切换偏多" + case strings.Contains(issueID, "long_block"): + return "同类任务连续块偏长" + case strings.Contains(issueID, "info"): + return "节奏整体提示" + default: + return fallbackLabel(dimension, "诊断问题") + } +} + +func fallbackLabel(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return strings.TrimSpace(fallback) + } + return strings.TrimSpace(value) +} diff --git a/backend/newAgent/tools/schedule_analysis/rhythm.go b/backend/newAgent/tools/schedule_analysis/rhythm.go new file mode 100644 index 0000000..bb94b9f --- /dev/null +++ b/backend/newAgent/tools/schedule_analysis/rhythm.go @@ -0,0 +1,326 @@ +package schedule_analysis + +import ( + "fmt" + "strings" +) + +// BuildAnalyzeRhythmView 把 analyze_rhythm 的原始 JSON observation 转成诊断卡片。 +// +// 步骤化说明: +// 1. 只读取现有 metrics / issues / next_actions,不改变 observation JSON; +// 2. collapsed 聚焦节律结论和关键指标,expanded 展开问题日、问题清单和建议动作; +// 3. detail / hard_categories 等参数只在父包参数区回显,不在这里声明它们已影响算法。 +func BuildAnalyzeRhythmView(input AnalyzeRhythmViewInput) AnalysisResultView { + payload, ok := parseObservationJSON(input.Observation) + if !ok || !isSuccessPayload(payload) { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "analyze_rhythm", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + metricsMap := readMap(payload, "metrics") + overview := readMap(metricsMap, "overview") + days := readList(metricsMap, "days") + issues := readList(payload, "issues") + actions := readList(payload, "next_actions") + + actionItems := buildRhythmActionItems(actions) + problemDayItems := buildRhythmProblemDayItems(days) + issueItems := buildIssueItems(issues) + + sections := []map[string]any{ + BuildKVSection("节律概览", buildRhythmOverviewFields(overview)), + } + if len(problemDayItems) > 0 { + sections = append(sections, BuildItemsSection("问题日", problemDayItems)) + } else { + sections = append(sections, BuildCalloutSection("问题日", "当前没有命中的高风险问题日。", "info", nil)) + } + if len(issueItems) > 0 { + sections = append(sections, BuildItemsSection("问题清单", issueItems)) + } + if len(actionItems) > 0 { + sections = append(sections, BuildItemsSection("建议动作", actionItems)) + } else { + sections = append(sections, BuildCalloutSection("建议动作", "当前节律诊断没有返回候选动作。", "info", nil)) + } + appendSectionIfPresent(§ions, BuildArgsSection("分析参数", input.ArgFields)) + + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: buildRhythmTitle(issues), + Subtitle: buildRhythmSubtitle(issues, overview), + Metrics: buildRhythmMetrics(overview), + Items: actionItems, + Sections: sections, + Observation: input.Observation, + MachinePayload: payload, + }) +} + +func buildRhythmTitle(issues []any) string { + severity := highestSeverity(issues) + switch severity { + case "critical": + return "学习节律分析:存在高风险" + case "warning": + return "学习节律分析:有待微调" + default: + return "学习节律分析:整体平稳" + } +} + +func buildRhythmSubtitle(issues []any, overview map[string]any) string { + if issue := firstHighPriorityIssue(issues); issue != nil { + return describeIssue(issue) + } + maxDay := readInt(overview, "max_switch_day") + maxSwitch := readInt(overview, "max_switch_count") + if maxDay > 0 { + return fmt.Sprintf("最大切换出现在第 %d 天,共 %d 次。", maxDay, maxSwitch) + } + return "当前学习节律没有明显异常信号。" +} + +func buildRhythmMetrics(overview map[string]any) []MetricField { + return []MetricField{ + BuildMetric("平均切换", fmt.Sprintf("%.1f 次/天", readFloat(overview, "avg_switches_per_day"))), + BuildMetric("最大切换", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))), + BuildMetric("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))), + BuildMetric("块平衡", fmt.Sprintf("%d", readInt(overview, "block_balance"))), + BuildMetric("同类型占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))), + } +} + +func buildRhythmOverviewFields(overview map[string]any) []KVField { + return []KVField{ + BuildKVField("平均每日切换", fmt.Sprintf("%.1f 次", readFloat(overview, "avg_switches_per_day"))), + BuildKVField("最大切换日", formatDayIndex(readInt(overview, "max_switch_day"))), + BuildKVField("最大切换次数", fmt.Sprintf("%d 次", readInt(overview, "max_switch_count"))), + BuildKVField("平均块长度", fmt.Sprintf("%.1f 节", readFloat(overview, "avg_block_size"))), + BuildKVField("最长同科连续", fmt.Sprintf("%d 节", readInt(overview, "longest_same_subject_run"))), + BuildKVField("高认知相邻", fmt.Sprintf("%d 天", readInt(overview, "heavy_adjacent_days"))), + BuildKVField("高强度连续过长", fmt.Sprintf("%d 天", readInt(overview, "long_high_intensity_days"))), + BuildKVField("偏碎天数", fmt.Sprintf("%d 天", readInt(overview, "fragmented_count"))), + BuildKVField("偏压缩天数", fmt.Sprintf("%d 天", readInt(overview, "compressed_run_count"))), + BuildKVField("同类型切换占比", formatPercent(readFloat(overview, "same_type_transition_ratio"))), + } +} + +func buildRhythmProblemDayItems(days []any) []ItemView { + if len(days) == 0 { + return make([]ItemView, 0) + } + items := make([]ItemView, 0) + for _, raw := range days { + day, ok := raw.(map[string]any) + if !ok || !isProblemRhythmDay(day) { + continue + } + dayIndex := readInt(day, "day_index") + switchCount := readInt(day, "switch_count") + fragmentation := readFloat(day, "fragmentation") + maxBlock := readInt(day, "max_block") + heavyAdjacent, _ := readBool(day, "heavy_adjacent") + tags := []string{} + if switchCount >= 3 || fragmentation >= 0.55 { + tags = append(tags, "偏碎") + } + if heavyAdjacent { + tags = append(tags, "高认知相邻") + } + if maxBlock >= 5 { + tags = append(tags, "连续块偏长") + } + detailLines := []string{ + fmt.Sprintf("切换次数:%d 次", switchCount), + "碎片化程度:" + formatFloat(fragmentation), + fmt.Sprintf("最长连续块:%d 节", maxBlock), + "科目序列:" + formatSequence(readList(day, "sequence")), + } + items = append(items, BuildItem( + formatDayIndex(dayIndex), + fmt.Sprintf("切换 %d 次,最长连续 %d 节", switchCount, maxBlock), + tags, + detailLines, + day, + )) + } + return items +} + +func buildRhythmActionItems(actions []any) []ItemView { + if len(actions) == 0 { + return make([]ItemView, 0) + } + items := make([]ItemView, 0, len(actions)) + for _, raw := range actions { + action, ok := raw.(map[string]any) + if !ok { + continue + } + scope := readMap(action, "candidate_scope") + title := formatIntentCN(readString(action, "intent_code")) + if title == "" { + title = fallbackLabel(readString(action, "action_id"), "建议动作") + } + reads := formatStringList(readList(action, "required_reads")) + writes := formatStringList(readList(action, "candidate_write_tools")) + tags := []string{ + fmt.Sprintf("优先级 %d", readInt(action, "priority")), + fallbackLabel(writes, "无写工具"), + } + detailLines := []string{ + "需要读取:" + fallbackLabel(reads, "无"), + "候选写工具:" + fallbackLabel(writes, "无"), + "作用范围:" + formatCandidateScope(scope), + "成功标准:" + compactJSON(action["success_criteria"]), + } + items = append(items, BuildItem( + title, + fmt.Sprintf("先读 %s,再考虑 %s", fallbackLabel(reads, "相关事实"), fallbackLabel(writes, "局部调整")), + tags, + detailLines, + action, + )) + } + return items +} + +func highestSeverity(issues []any) string { + best := "info" + bestRank := severityRank(best) + for _, raw := range issues { + issue, ok := raw.(map[string]any) + if !ok { + continue + } + severity := readString(issue, "severity") + if rank := severityRank(severity); rank < bestRank { + best = severity + bestRank = rank + } + } + return strings.ToLower(strings.TrimSpace(best)) +} + +func firstHighPriorityIssue(issues []any) map[string]any { + var best map[string]any + bestRank := 99 + for _, raw := range issues { + issue, ok := raw.(map[string]any) + if !ok { + continue + } + rank := severityRank(readString(issue, "severity")) + if best == nil || rank < bestRank { + best = issue + bestRank = rank + } + } + return best +} + +func isProblemRhythmDay(day map[string]any) bool { + heavyAdjacent, _ := readBool(day, "heavy_adjacent") + return readInt(day, "switch_count") >= 3 || + readFloat(day, "fragmentation") >= 0.55 || + readInt(day, "max_block") >= 5 || + heavyAdjacent +} + +func formatDayIndex(day int) string { + if day <= 0 { + return "未知日期" + } + return fmt.Sprintf("第 %d 天", day) +} + +func formatSequence(rows []any) string { + if len(rows) == 0 { + return "无" + } + parts := make([]string, 0, len(rows)) + for _, raw := range rows { + text := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if text == "" || text == "" { + continue + } + parts = append(parts, text) + } + if len(parts) == 0 { + return "无" + } + return strings.Join(parts, " -> ") +} + +func formatStringList(rows []any) string { + if len(rows) == 0 { + return "" + } + parts := make([]string, 0, len(rows)) + for _, raw := range rows { + text := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if text == "" || text == "" { + continue + } + parts = append(parts, text) + } + return strings.Join(parts, "、") +} + +func formatCandidateScope(scope map[string]any) string { + if len(scope) == 0 { + return "未返回" + } + parts := make([]string, 0, 3) + if days := formatNumberList(readList(scope, "day_range"), "第 %d 天"); days != "" { + parts = append(parts, "日期:"+days) + } + if categories := formatStringList(readList(scope, "categories")); categories != "" { + parts = append(parts, "类别:"+categories) + } + if pool := readString(scope, "task_pool"); pool != "" { + parts = append(parts, "任务池:"+pool) + } + if len(parts) == 0 { + return compactJSON(scope) + } + return strings.Join(parts, ";") +} + +func formatNumberList(rows []any, pattern string) string { + if len(rows) == 0 { + return "" + } + parts := make([]string, 0, len(rows)) + for _, raw := range rows { + number := 0 + switch typed := raw.(type) { + case float64: + number = int(typed) + case int: + number = typed + default: + continue + } + parts = append(parts, fmt.Sprintf(pattern, number)) + } + return strings.Join(parts, "、") +} + +func formatIntentCN(intent string) string { + switch strings.TrimSpace(intent) { + case "reduce_switch": + return "减少同日切换" + case "smooth_rhythm": + return "平滑高认知相邻" + case "prefer_swap": + return "优先寻找交换机会" + default: + return strings.TrimSpace(intent) + } +} diff --git a/backend/newAgent/tools/schedule_analysis/types.go b/backend/newAgent/tools/schedule_analysis/types.go new file mode 100644 index 0000000..37d761e --- /dev/null +++ b/backend/newAgent/tools/schedule_analysis/types.go @@ -0,0 +1,87 @@ +package schedule_analysis + +const ( + // ViewTypeAnalysisResult 是第三批诊断分析结果卡片的前端识别类型。 + ViewTypeAnalysisResult = "schedule.analysis_result" + + // ViewVersionAnalysisResult 是当前诊断分析结果结构版本。 + ViewVersionAnalysisResult = 1 + + // 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。 + StatusDone = "done" + StatusFailed = "failed" + StatusBlocked = "blocked" +) + +// AnalysisResultView 是子包暴露给父包 adapter 的纯展示结构。 +// +// 职责边界: +// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据; +// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议; +// 3. collapsed / expanded 保持 map 形态,方便父包直接桥接到现有展示协议。 +type AnalysisResultView struct { + ViewType string `json:"view_type"` + Version int `json:"version"` + Collapsed map[string]any `json:"collapsed"` + Expanded map[string]any `json:"expanded"` +} + +// KVField 是展开态 kv section 的轻量键值结构。 +type KVField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// MetricField 是 collapsed.metrics 的轻量键值结构。 +type MetricField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// ItemView 是 expanded.items / section.items 的通用结构。 +type ItemView struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Tags []string `json:"tags"` + DetailLines []string `json:"detail_lines"` + Meta map[string]any `json:"meta,omitempty"` +} + +// BuildResultViewInput 是通用 analysis 结果视图 builder 的输入。 +// +// 职责边界: +// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区; +// 2. 不负责执行分析工具,observation 必须由父包 adapter 传入; +// 3. observation 会原样写入 raw_text,不能在这里改写给下游消费的 JSON。 +type BuildResultViewInput struct { + Status string + Title string + Subtitle string + Metrics []MetricField + Items []ItemView + Sections []map[string]any + Observation string + MachinePayload map[string]any +} + +// BuildFailureViewInput 是失败视图 builder 的输入。 +type BuildFailureViewInput struct { + ToolName string + Status string + Title string + Subtitle string + Observation string + ArgFields []KVField +} + +// AnalyzeHealthViewInput 是 analyze_health 视图构造输入。 +type AnalyzeHealthViewInput struct { + Observation string + ArgFields []KVField +} + +// AnalyzeRhythmViewInput 是 analyze_rhythm 视图构造输入。 +type AnalyzeRhythmViewInput struct { + Observation string + ArgFields []KVField +} diff --git a/backend/newAgent/tools/schedule_analysis_handlers.go b/backend/newAgent/tools/schedule_analysis_handlers.go new file mode 100644 index 0000000..26eafa3 --- /dev/null +++ b/backend/newAgent/tools/schedule_analysis_handlers.go @@ -0,0 +1,196 @@ +package newagenttools + +import ( + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + scheduleanalysis "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule_analysis" +) + +type scheduleAnalyzeObserveFunc func(state *schedule.ScheduleState, args map[string]any) string +type scheduleAnalyzeViewBuilder func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView + +// scheduleAnalysisAdapterInput 是父包传给 schedule_analysis 子包前的最小上下文。 +// +// 职责边界: +// 1. 只携带展示构造需要的 observation 与已本地化参数字段; +// 2. 不把 ToolExecutionResult / ToolArgumentView 传入子包,避免反向依赖父包; +// 3. ObservationText 必须原样来自底层 schedule.AnalyzeXxx,不在 adapter 层改写。 +type scheduleAnalysisAdapterInput struct { + ToolName string + Args map[string]any + State *schedule.ScheduleState + ObservationText string + ArgFields []scheduleanalysis.KVField +} + +// NewAnalyzeHealthToolHandler 为 analyze_health 生成结构化诊断结果。 +func NewAnalyzeHealthToolHandler() ToolHandler { + return newScheduleAnalyzeToolHandler( + "analyze_health", + schedule.AnalyzeHealth, + func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView { + return scheduleanalysis.BuildAnalyzeHealthView(scheduleanalysis.AnalyzeHealthViewInput{ + Observation: input.ObservationText, + ArgFields: input.ArgFields, + }) + }, + ) +} + +// NewAnalyzeRhythmToolHandler 为 analyze_rhythm 生成结构化诊断结果。 +func NewAnalyzeRhythmToolHandler() ToolHandler { + return newScheduleAnalyzeToolHandler( + "analyze_rhythm", + schedule.AnalyzeRhythm, + func(input scheduleAnalysisAdapterInput) scheduleanalysis.AnalysisResultView { + return scheduleanalysis.BuildAnalyzeRhythmView(scheduleanalysis.AnalyzeRhythmViewInput{ + Observation: input.ObservationText, + ArgFields: input.ArgFields, + }) + }, + ) +} + +// newScheduleAnalyzeToolHandler 统一构造父包 analysis adapter。 +// +// 步骤化说明: +// 1. 先调用现有 schedule.AnalyzeXxx,确保 state_snapshot / prompt 摘要消费的 JSON 完全不变; +// 2. 再用 LegacyResultWithState 复用父包参数展示、状态判断和错误信息提取; +// 3. 最后调用 schedule_analysis 子包生成纯展示视图,并包回 ToolExecutionResult。 +func newScheduleAnalyzeToolHandler( + toolName string, + observe scheduleAnalyzeObserveFunc, + buildView scheduleAnalyzeViewBuilder, +) ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + observation := observe(state, args) + legacy := LegacyResultWithState(toolName, args, state, observation) + input := scheduleAnalysisAdapterInput{ + ToolName: toolName, + Args: cloneAnyMap(args), + State: state, + ObservationText: observation, + ArgFields: extractScheduleAnalysisArgumentFields(legacy.ArgumentView), + } + return buildScheduleAnalysisExecutionResult(legacy, args, buildView(input)) + } +} + +// buildScheduleAnalysisExecutionResult 负责把子包纯展示视图包回父包统一协议。 +// +// 职责边界: +// 1. 只做 AnalysisResultView -> ToolDisplayView 的协议桥接; +// 2. 不改写 ObservationText,确保主动优化状态快照仍读取原始 JSON; +// 3. 错误码与错误文案继续复用父包既有 JSON / 文本解析逻辑。 +func buildScheduleAnalysisExecutionResult( + legacy ToolExecutionResult, + args map[string]any, + view scheduleanalysis.AnalysisResultView, +) ToolExecutionResult { + result := legacy + status := normalizeToolStatus(result.Status) + if status == "" { + status = ToolStatusDone + } + if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok { + if normalized := normalizeToolStatus(collapsedStatus); normalized != "" { + status = normalized + } + } + + collapsed := cloneAnyMap(view.Collapsed) + if collapsed == nil { + collapsed = make(map[string]any) + } + expanded := cloneAnyMap(view.Expanded) + if expanded == nil { + expanded = make(map[string]any) + } + + collapsed["status"] = status + if _, exists := collapsed["status_label"]; !exists { + collapsed["status_label"] = resolveToolStatusLabelCN(status) + } + if _, exists := expanded["raw_text"]; !exists { + expanded["raw_text"] = strings.TrimSpace(result.ObservationText) + } + + viewType := strings.TrimSpace(view.ViewType) + if viewType == "" { + viewType = scheduleanalysis.ViewTypeAnalysisResult + } + version := view.Version + if version <= 0 { + version = scheduleanalysis.ViewVersionAnalysisResult + } + + result.Status = status + result.Success = status == ToolStatusDone + result.ResultView = &ToolDisplayView{ + ViewType: viewType, + Version: version, + Collapsed: collapsed, + Expanded: expanded, + } + if title, ok := readStringAnyMap(collapsed, "title"); ok { + result.Summary = title + } + if !result.Success { + errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status) + if strings.TrimSpace(result.ErrorCode) == "" { + result.ErrorCode = strings.TrimSpace(errorCode) + } + if strings.TrimSpace(result.ErrorMessage) == "" { + result.ErrorMessage = strings.TrimSpace(errorMessage) + } + } + return EnsureToolResultDefaults(result, args) +} + +// extractScheduleAnalysisArgumentFields 把父包 ToolArgumentView 投影成子包可消费的 KVField。 +// +// 说明: +// 1. 参数字段只做回显,尤其 detail / threshold / hard_categories 不在这里解释为真实生效; +// 2. 子包只接收中文 label/display,避免理解父包参数 view 结构; +// 3. 字段缺失时返回空切片,由子包跳过参数 section。 +func extractScheduleAnalysisArgumentFields(view *ToolArgumentView) []scheduleanalysis.KVField { + if view == nil || view.Expanded == nil { + return make([]scheduleanalysis.KVField, 0) + } + rawFields, exists := view.Expanded["fields"] + if !exists { + return make([]scheduleanalysis.KVField, 0) + } + + fields := make([]scheduleanalysis.KVField, 0) + appendField := func(row map[string]any) { + label, _ := row["label"].(string) + display, _ := row["display"].(string) + label = strings.TrimSpace(label) + display = strings.TrimSpace(display) + if label == "" || display == "" { + return + } + fields = append(fields, scheduleanalysis.BuildKVField(label, display)) + } + + switch typed := rawFields.(type) { + case []map[string]any: + for _, row := range typed { + appendField(row) + } + case []any: + for _, item := range typed { + row, ok := item.(map[string]any) + if !ok { + continue + } + appendField(row) + } + } + if len(fields) == 0 { + return make([]scheduleanalysis.KVField, 0) + } + return fields +} diff --git a/backend/newAgent/tools/schedule_argument_format_helpers.go b/backend/newAgent/tools/schedule_argument_format_helpers.go new file mode 100644 index 0000000..82022b0 --- /dev/null +++ b/backend/newAgent/tools/schedule_argument_format_helpers.go @@ -0,0 +1,143 @@ +package newagenttools + +import ( + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// formatScheduleDayCN 为参数展示生成中文日期标签。 +// +// 职责边界: +// 1. 只服务父包 ArgumentView 的中文展示; +// 2. 不参与 schedule.read_result 卡片构造,read 卡片已切到 schedule_read 子包; +// 3. 当 state 缺失或日期映射不存在时,退回稳定的“第 N 天”文本。 +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 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 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 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 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_queue_handlers.go b/backend/newAgent/tools/schedule_queue_handlers.go new file mode 100644 index 0000000..265a4b7 --- /dev/null +++ b/backend/newAgent/tools/schedule_queue_handlers.go @@ -0,0 +1,486 @@ +package newagenttools + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + scheduleread "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule_read" +) + +type queueTaskSlotSnapshot 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 queueTaskSnapshotPayload struct { + TaskID int `json:"task_id"` + Name string `json:"name"` + Category string `json:"category,omitempty"` + Status string `json:"status"` + Duration int `json:"duration,omitempty"` + TaskClassID int `json:"task_class_id,omitempty"` + Slots []queueTaskSlotSnapshot `json:"slots,omitempty"` +} + +type queuePopHeadPayload struct { + Tool string `json:"tool"` + HasHead bool `json:"has_head"` + PendingCount int `json:"pending_count"` + CompletedCount int `json:"completed_count"` + SkippedCount int `json:"skipped_count"` + Current *queueTaskSnapshotPayload `json:"current,omitempty"` + LastError string `json:"last_error,omitempty"` + Error string `json:"error,omitempty"` +} + +type queueSkipHeadPayload struct { + Tool string `json:"tool"` + Success bool `json:"success"` + SkippedTaskID int `json:"skipped_task_id,omitempty"` + PendingCount int `json:"pending_count"` + SkippedCount int `json:"skipped_count"` + Reason string `json:"reason,omitempty"` + Error string `json:"error,omitempty"` +} + +// NewQueuePopHeadToolHandler 返回 queue_pop_head 的结构化读卡片。 +// +// 设计说明: +// 1. 这个工具本质是“读取当前队首处理对象”,因此继续走 schedule.read_result; +// 2. 不修改 schedule_read 子包,只在父包做一个轻量 adapter,复用既有 read 卡片协议; +// 3. 原始 ObservationText 继续保留 JSON 字符串,供 execute/timeline/模型链路复用。 +func NewQueuePopHeadToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + observation := schedule.QueuePopHead(state, args) + legacy := LegacyResultWithState("queue_pop_head", args, state, observation) + argFields := extractScheduleReadArgumentFields(legacy.ArgumentView) + + payload, machinePayload, ok := decodeQueuePopHeadPayload(observation) + if !ok || normalizeToolStatus(legacy.Status) != ToolStatusDone { + view := scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{ + ToolName: "queue_pop_head", + Status: legacy.Status, + Observation: observation, + ArgFields: argFields, + }) + return buildScheduleReadExecutionResult(legacy, args, view) + } + + view := buildQueuePopHeadReadView(state, observation, payload, machinePayload, argFields) + return buildScheduleReadExecutionResult(legacy, args, view) + } +} + +// NewQueueSkipHeadToolHandler 返回 queue_skip_head 的结构化操作卡片。 +// +// 设计说明: +// 1. 这个工具会改变 RuntimeQueue,因此继续落在 schedule.operation_result 语义下; +// 2. 但它不涉及日程位移,所以这里不强行复用 task change 列表,只展示队列前后快照; +// 3. 这样能去掉 legacy wrapper,同时避免把 queue 小尾巴抽成新的大协议。 +func NewQueueSkipHeadToolHandler() ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + beforeState := cloneScheduleStateOrNil(state) + beforeQueue := snapshotQueue(beforeState) + currentTaskID := beforeQueue.CurrentTaskID + currentTask := snapshotTask(beforeState, currentTaskID) + + observation := schedule.QueueSkipHead(state, args) + + afterState := cloneScheduleStateOrNil(state) + afterQueue := snapshotQueue(afterState) + legacy := LegacyResultWithState("queue_skip_head", args, afterState, observation) + + payload, machinePayload, ok := decodeQueueSkipHeadPayload(observation) + success := false + if ok { + success = payload.Success + } + if !success { + success = currentTaskID > 0 && + (afterQueue.SkippedCount > beforeQueue.SkippedCount) && + (afterQueue.CurrentTaskID != currentTaskID) + } + + return buildQueueSkipHeadExecutionResult( + legacy, + args, + observation, + success, + beforeQueue, + afterQueue, + currentTask, + payload, + machinePayload, + ) + } +} + +func buildQueuePopHeadReadView( + state *schedule.ScheduleState, + observation string, + payload queuePopHeadPayload, + machinePayload map[string]any, + argFields []scheduleread.KVField, +) scheduleread.ReadResultView { + items := make([]scheduleread.ItemView, 0, 1) + sections := make([]map[string]any, 0, 4) + + if payload.Current != nil { + currentItem := buildQueuePopHeadCurrentItem(state, payload.Current) + items = append(items, currentItem) + sections = append(sections, scheduleread.BuildItemsSection("当前处理", []scheduleread.ItemView{currentItem})) + } + + sections = append(sections, scheduleread.BuildKVSection("队列快照", []scheduleread.KVField{ + scheduleread.BuildKVField("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), + scheduleread.BuildKVField("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), + scheduleread.BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)), + scheduleread.BuildKVField("当前队首", buildQueuePopHeadCurrentLabel(payload.Current)), + })) + + if payload.HasHead { + sections = append(sections, buildQueueReadCalloutSection( + "队首任务已就位", + "可以继续调用 queue_apply_head_move 或 queue_skip_head。", + "info", + buildQueuePopHeadHintLines(payload), + )) + } else { + sections = append(sections, buildQueueReadCalloutSection( + "当前没有可处理任务", + "队列里没有 pending/current 任务,可以结束队列链路或重新 enqueue。", + "warning", + buildQueuePopHeadHintLines(payload), + )) + } + + if strings.TrimSpace(payload.LastError) != "" { + sections = append(sections, buildQueueReadCalloutSection( + "最近一次失败原因", + strings.TrimSpace(payload.LastError), + "warning", + []string{strings.TrimSpace(payload.LastError)}, + )) + } + + if argsSection := scheduleread.BuildArgsSection("查询条件", argFields); argsSection != nil { + sections = append(sections, argsSection) + } + + return scheduleread.BuildResultView(scheduleread.BuildResultViewInput{ + Status: scheduleread.StatusDone, + Title: buildQueuePopHeadTitle(payload), + Subtitle: buildQueuePopHeadSubtitle(payload), + Metrics: buildQueuePopHeadMetrics(payload), + Items: items, + Sections: sections, + Observation: observation, + MachinePayload: machinePayload, + }) +} + +func buildQueueSkipHeadExecutionResult( + legacy ToolExecutionResult, + args map[string]any, + observation string, + success bool, + beforeQueue scheduleQueueSnapshot, + afterQueue scheduleQueueSnapshot, + currentTask scheduleTaskSnapshot, + payload queueSkipHeadPayload, + machinePayload map[string]any, +) ToolExecutionResult { + result := legacy + status := ToolStatusFailed + if success { + status = ToolStatusDone + } + + taskLabel := resolveChangeTaskLabel(currentTask, currentTask) + queueSnapshot := buildQueueSnapshotWithLabels(beforeQueue, afterQueue) + if len(queueSnapshot) == 0 { + queueSnapshot = make(map[string]any) + } + queueSnapshot["summary_label"] = buildQueueSkipSnapshotTitle(success, taskLabel) + if strings.TrimSpace(payload.Reason) != "" { + queueSnapshot["skip_reason"] = strings.TrimSpace(payload.Reason) + } + if strings.TrimSpace(taskLabel) != "" { + queueSnapshot["skipped_task_label"] = strings.TrimSpace(taskLabel) + } + + title := buildQueueSkipHeadTitle(success) + subtitle := buildQueueSkipHeadSubtitle(success, taskLabel, payload.Reason) + collapsed := map[string]any{ + "title": title, + "subtitle": subtitle, + "status": status, + "status_label": resolveToolStatusLabelCN(status), + "operation": "queue_skip_head", + "operation_label": resolveToolLabelCN("queue_skip_head"), + "metrics": []map[string]any{ + {"label": "待处理", "value": fmt.Sprintf("%d 项", afterQueue.PendingCount)}, + {"label": "已跳过", "value": fmt.Sprintf("%d 项", afterQueue.SkippedCount)}, + {"label": "当前队首", "value": buildQueueCurrentMetricValue(afterQueue.CurrentTaskID)}, + }, + } + expanded := map[string]any{ + "operation": "queue_skip_head", + "operation_label": resolveToolLabelCN("queue_skip_head"), + "queue_snapshot": queueSnapshot, + "raw_text": observation, + } + if len(machinePayload) > 0 { + expanded["machine_payload"] = machinePayload + } + if !success { + expanded["failure_reason"] = strings.TrimSpace(pickFailureReason(observation, false)) + } + + result.Status = status + result.Success = success + result.Summary = title + result.ResultView = &ToolDisplayView{ + ViewType: "schedule.operation_result", + Version: 1, + Collapsed: collapsed, + Expanded: expanded, + } + if !success { + errorCode, errorMessage := extractToolErrorInfo(observation, status) + if strings.TrimSpace(result.ErrorCode) == "" { + result.ErrorCode = strings.TrimSpace(errorCode) + } + if strings.TrimSpace(result.ErrorMessage) == "" { + result.ErrorMessage = strings.TrimSpace(errorMessage) + } + } + return EnsureToolResultDefaults(result, args) +} + +func decodeQueuePopHeadPayload(observation string) (queuePopHeadPayload, map[string]any, bool) { + var payload queuePopHeadPayload + trimmed := strings.TrimSpace(observation) + if trimmed == "" { + return payload, nil, false + } + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return payload, nil, false + } + raw, ok := parseObservationJSON(trimmed) + return payload, raw, ok +} + +func decodeQueueSkipHeadPayload(observation string) (queueSkipHeadPayload, map[string]any, bool) { + var payload queueSkipHeadPayload + trimmed := strings.TrimSpace(observation) + if trimmed == "" { + return payload, nil, false + } + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return payload, nil, false + } + raw, ok := parseObservationJSON(trimmed) + return payload, raw, ok +} + +func buildQueuePopHeadTitle(payload queuePopHeadPayload) string { + if payload.HasHead { + return "已获取队首任务" + } + return "当前队列无可处理任务" +} + +func buildQueuePopHeadSubtitle(payload queuePopHeadPayload) string { + if payload.Current != nil { + return fmt.Sprintf("%s,待处理 %d 项。", buildQueueTaskLabel(payload.Current), payload.PendingCount) + } + if strings.TrimSpace(payload.LastError) != "" { + return "当前没有队首任务,最近一次失败原因已保留。" + } + return "没有 pending/current 任务,可结束队列链路或重新入队。" +} + +func buildQueuePopHeadMetrics(payload queuePopHeadPayload) []scheduleread.MetricField { + return []scheduleread.MetricField{ + scheduleread.BuildMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), + scheduleread.BuildMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), + scheduleread.BuildMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)), + } +} + +func buildQueuePopHeadCurrentItem(state *schedule.ScheduleState, payload *queueTaskSnapshotPayload) scheduleread.ItemView { + if payload == nil { + return scheduleread.BuildItem("当前无队首任务", "", nil, nil, nil) + } + tags := []string{"当前处理", resolveTaskStatusLabelCN(payload.Status)} + if payload.Duration > 0 { + tags = append(tags, fmt.Sprintf("%d 节", payload.Duration)) + } + return scheduleread.BuildItem( + buildQueueTaskLabel(payload), + buildQueueTaskSubtitle(payload), + tags, + buildQueueTaskDetailLines(state, payload), + map[string]any{ + "task_id": payload.TaskID, + "task_class_id": payload.TaskClassID, + "status": payload.Status, + }, + ) +} + +func buildQueuePopHeadCurrentLabel(payload *queueTaskSnapshotPayload) string { + if payload == nil { + return "无" + } + return buildQueueTaskLabel(payload) +} + +func buildQueuePopHeadHintLines(payload queuePopHeadPayload) []string { + lines := []string{ + fmt.Sprintf("待处理:%d 项", payload.PendingCount), + fmt.Sprintf("已完成:%d 项", payload.CompletedCount), + fmt.Sprintf("已跳过:%d 项", payload.SkippedCount), + } + if payload.Current != nil { + lines = append(lines, fmt.Sprintf("当前队首:%s", buildQueueTaskLabel(payload.Current))) + } + return lines +} + +func buildQueueSkipHeadTitle(success bool) string { + if success { + return "已跳过队首任务" + } + return "跳过队首任务失败" +} + +func buildQueueSkipHeadSubtitle(success bool, taskLabel string, reason string) string { + if success { + if strings.TrimSpace(taskLabel) != "" { + return fmt.Sprintf("已将 %s 标记为 skipped,可继续 queue_pop_head。", strings.TrimSpace(taskLabel)) + } + return "已跳过当前队首任务,可继续 queue_pop_head。" + } + if strings.TrimSpace(reason) != "" { + return strings.TrimSpace(reason) + } + return "当前没有可跳过的队首任务。" +} + +func buildQueueSkipSnapshotTitle(success bool, taskLabel string) string { + if success && strings.TrimSpace(taskLabel) != "" { + return fmt.Sprintf("已跳过 %s", strings.TrimSpace(taskLabel)) + } + if success { + return "队列已跳过当前队首" + } + return "队列状态未变更" +} + +func buildQueueCurrentMetricValue(taskID int) string { + if taskID <= 0 { + return "无" + } + return fmt.Sprintf("%d", taskID) +} + +func buildQueueTaskLabel(payload *queueTaskSnapshotPayload) string { + if payload == nil { + return "任务" + } + name := strings.TrimSpace(payload.Name) + if name == "" { + return fmt.Sprintf("[%d]任务", payload.TaskID) + } + return fmt.Sprintf("[%d]%s", payload.TaskID, name) +} + +func buildQueueTaskSubtitle(payload *queueTaskSnapshotPayload) string { + if payload == nil { + return "" + } + category := strings.TrimSpace(payload.Category) + status := resolveTaskStatusLabelCN(payload.Status) + if category == "" { + return status + } + return fmt.Sprintf("%s,%s", category, status) +} + +func buildQueueTaskDetailLines(state *schedule.ScheduleState, payload *queueTaskSnapshotPayload) []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, buildQueueSlotLabel(state, slot)) + } + 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 buildQueueSlotLabel(state *schedule.ScheduleState, slot queueTaskSlotSnapshot) string { + dayLabel := formatDayLabelCN(slot.Day) + if state != nil { + if week, dayOfWeek, ok := state.DayToWeekDay(slot.Day); ok { + dayLabel = fmt.Sprintf("%s(第%d周 周%d)", formatDayLabelCN(slot.Day), week, dayOfWeek) + } + } + if slot.Week > 0 && slot.DayOfWeek > 0 { + dayLabel = fmt.Sprintf("%s(第%d周 周%d)", formatDayLabelCN(slot.Day), slot.Week, slot.DayOfWeek) + } + return fmt.Sprintf("%s %s", dayLabel, formatSlotRangeCN(slot.SlotStart, slot.SlotEnd)) +} + +func buildQueueReadCalloutSection(title string, summary string, tone string, detailLines []string) map[string]any { + return map[string]any{ + "type": "callout", + "title": strings.TrimSpace(title), + "summary": strings.TrimSpace(summary), + "tone": strings.TrimSpace(tone), + "detail_lines": normalizeQueueDetailLines(detailLines), + } +} + +func normalizeQueueDetailLines(lines []string) []string { + if len(lines) == 0 { + return nil + } + out := make([]string, 0, len(lines)) + for _, line := range lines { + text := strings.TrimSpace(line) + if text == "" { + continue + } + out = append(out, text) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneScheduleStateOrNil(state *schedule.ScheduleState) *schedule.ScheduleState { + if state == nil { + return nil + } + return state.Clone() +} diff --git a/backend/newAgent/tools/schedule_read_result_common.go b/backend/newAgent/tools/schedule_read/common.go similarity index 51% rename from backend/newAgent/tools/schedule_read_result_common.go rename to backend/newAgent/tools/schedule_read/common.go index 099ff0b..443b29b 100644 --- a/backend/newAgent/tools/schedule_read_result_common.go +++ b/backend/newAgent/tools/schedule_read/common.go @@ -1,6 +1,7 @@ -package newagenttools +package schedule_read import ( + "encoding/json" "fmt" "sort" "strings" @@ -8,127 +9,134 @@ import ( "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" ) -// buildScheduleReadResult 统一封装第二批 read 工具的结构化返回。 +// BuildResultView 统一封装 schedule.read_result 结构。 // // 职责边界: -// 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) +// 1. 负责把已经计算好的折叠态/展开态内容组装成标准视图。 +// 2. 负责在子包内补齐 status / status_label,避免依赖父包常量。 +// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。 +func BuildResultView(input BuildResultViewInput) ReadResultView { + status := normalizeStatus(input.Status) + if status == "" { + status = StatusDone } - expanded := map[string]any{ - "items": items, - "sections": sections, - "raw_text": strings.TrimSpace(observation), + collapsed := CollapsedView{ + Title: input.Title, + Subtitle: input.Subtitle, + Status: status, + StatusLabel: resolveStatusLabelCN(status), + Metrics: appendMetricCopy(input.Metrics), } - if len(machinePayload) > 0 { - expanded["machine_payload"] = cloneAnyMap(machinePayload) + expanded := ExpandedView{ + Items: appendItemCopy(input.Items), + Sections: cloneSectionList(input.Sections), + RawText: input.Observation, + MachinePayload: cloneAnyMap(input.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), + return ReadResultView{ + ViewType: ViewTypeReadResult, + Version: ViewVersionReadResult, + Collapsed: collapsed.Map(), + Expanded: expanded.Map(), } } -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), +// BuildFailureView 统一生成 read 工具失败卡片视图。 +// +// 职责边界: +// 1. 负责把失败 observation 提炼成展开态提示与参数回显。 +// 2. 不负责决定是否要失败;调用方需要在进入这里前确认失败条件。 +// 3. 若标题、副标题未显式传入,则按工具名与 observation 兜底生成。 +func BuildFailureView(input BuildFailureViewInput) ReadResultView { + status := normalizeStatus(input.Status) + if status == "" { + status = StatusFailed } - if len(meta) > 0 { - item["meta"] = cloneAnyMap(meta) + + title := strings.TrimSpace(input.Title) + if title == "" { + title = fmt.Sprintf("%s失败", resolveToolLabelCN(input.ToolName)) } - return item + + subtitle := strings.TrimSpace(input.Subtitle) + if subtitle == "" { + subtitle = trimFailureText(input.Observation, "请检查筛选条件后重试。") + } + + return BuildResultView(BuildResultViewInput{ + Status: status, + Title: title, + Subtitle: subtitle, + Sections: buildReadFailureSections(input.ArgFields, input.Observation), + Observation: input.Observation, + }) } -func buildScheduleReadKV(label string, value string) map[string]any { - return map[string]any{ - "label": strings.TrimSpace(label), - "value": strings.TrimSpace(value), +// BuildMetric 是 collapsed.metrics 的便捷构造器。 +func BuildMetric(label string, value string) MetricField { + return MetricField{ + 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) +// BuildKVField 是 kv section 的便捷构造器。 +func BuildKVField(label string, value string) KVField { + return KVField{ + Label: strings.TrimSpace(label), + Value: strings.TrimSpace(value), + } +} + +// BuildItem 是 items 的便捷构造器。 +func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView { + return ItemView{ + Title: strings.TrimSpace(title), + Subtitle: strings.TrimSpace(subtitle), + Tags: normalizeStringSlice(tags), + DetailLines: normalizeStringSlice(detailLines), + Meta: cloneAnyMap(meta), + } +} + +// BuildItemsSection 把条目列表包装成 items section。 +func BuildItemsSection(title string, items []ItemView) map[string]any { + normalized := make([]map[string]any, 0, len(items)) + for _, item := range items { + normalized = append(normalized, item.Map()) } return map[string]any{ "type": "items", "title": strings.TrimSpace(title), - "items": items, + "items": normalized, } } -func buildScheduleReadKVSection(title string, fields []map[string]any) map[string]any { - if fields == nil { - fields = make([]map[string]any, 0) +// BuildKVSection 把 kv 列表包装成 kv section。 +func BuildKVSection(title string, fields []KVField) map[string]any { + normalized := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + label := strings.TrimSpace(field.Label) + value := strings.TrimSpace(field.Value) + if label == "" || value == "" { + continue + } + normalized = append(normalized, map[string]any{ + "label": label, + "value": value, + }) } return map[string]any{ "type": "kv", "title": strings.TrimSpace(title), - "fields": fields, + "fields": normalized, } } -func buildScheduleReadCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any { +// BuildCalloutSection 把提示块包装成 callout section。 +func BuildCalloutSection(title string, subtitle string, tone string, detailLines []string) map[string]any { return map[string]any{ "type": "callout", "title": strings.TrimSpace(title), @@ -138,76 +146,40 @@ func buildScheduleReadCalloutSection(title string, subtitle string, tone string, } } -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)) - } +// BuildArgsSection 负责把父包已经格式化好的参数字段拼成查询条件 section。 +// +// 职责边界: +// 1. 这里只接受纯 KVField,不依赖父包 ToolArgumentView。 +// 2. 只过滤空 label / value,不补充额外解释文案。 +// 3. 没有有效字段时返回 nil,交给调用方决定是否追加 section。 +func BuildArgsSection(title string, fields []KVField) map[string]any { if len(fields) == 0 { return nil } - return buildScheduleReadKVSection(title, fields) + valid := make([]KVField, 0, len(fields)) + for _, field := range fields { + label := strings.TrimSpace(field.Label) + value := strings.TrimSpace(field.Value) + if label == "" || value == "" { + continue + } + valid = append(valid, BuildKVField(label, value)) + } + if len(valid) == 0 { + return nil + } + return BuildKVSection(title, valid) } -func buildReadFailureSections(argView *ToolArgumentView, observation string) []map[string]any { +func buildReadFailureSections(argFields []KVField, observation string) []map[string]any { message := trimFailureText(observation, "读取结果失败,请检查参数后重试。") sections := []map[string]any{ - buildScheduleReadCalloutSection("执行失败", message, "danger", []string{message}), + BuildCalloutSection("执行失败", message, "danger", []string{message}), } - appendSectionIfPresent(§ions, buildScheduleReadArgsSection("查询条件", argView)) + appendSectionIfPresent(§ions, BuildArgsSection("查询条件", argFields)) 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 @@ -215,6 +187,79 @@ func appendSectionIfPresent(target *[]map[string]any, section map[string]any) { *target = append(*target, section) } +func appendMetricCopy(metrics []MetricField) []MetricField { + if len(metrics) == 0 { + return make([]MetricField, 0) + } + out := make([]MetricField, 0, len(metrics)) + for _, metric := range metrics { + label := strings.TrimSpace(metric.Label) + value := strings.TrimSpace(metric.Value) + if label == "" || value == "" { + continue + } + out = append(out, MetricField{Label: label, Value: value}) + } + if len(out) == 0 { + return make([]MetricField, 0) + } + return out +} + +func appendItemCopy(items []ItemView) []ItemView { + if len(items) == 0 { + return make([]ItemView, 0) + } + out := make([]ItemView, 0, len(items)) + for _, item := range items { + out = append(out, BuildItem(item.Title, item.Subtitle, item.Tags, item.DetailLines, item.Meta)) + } + return out +} + +func normalizeStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case StatusDone: + return StatusDone + case StatusBlocked: + return StatusBlocked + case StatusFailed: + return StatusFailed + default: + return "" + } +} + +func resolveStatusLabelCN(status string) string { + switch normalizeStatus(status) { + case StatusDone: + return "已完成" + case StatusBlocked: + return "已阻断" + default: + return "失败" + } +} + +func resolveToolLabelCN(toolName string) string { + switch strings.TrimSpace(toolName) { + case "query_available_slots": + return "查询可用时段" + case "query_range": + return "查询范围" + case "query_target_tasks": + return "查询目标任务" + case "get_task_info": + return "查询任务详情" + case "get_overview": + return "查看排程总览" + case "queue_status": + return "查看队列状态" + default: + return "读取结果" + } +} + func normalizeStringSlice(values []string) []string { if len(values) == 0 { return make([]string, 0) @@ -234,10 +279,10 @@ func normalizeStringSlice(values []string) []string { } func trimFailureText(observation string, fallback string) string { - status, _ := resolveToolStatusAndSuccess(observation) - _, message := extractToolErrorInfo(observation, status) - if strings.TrimSpace(message) != "" { - return strings.TrimSpace(message) + if payload, ok := parseObservationJSON(observation); ok { + if message, ok := readStringFromMap(payload, "error", "err", "message", "reason"); ok && strings.TrimSpace(message) != "" { + return strings.TrimSpace(message) + } } if strings.TrimSpace(observation) != "" { return strings.TrimSpace(observation) @@ -484,11 +529,23 @@ func countScheduleDayTaskOccupiedForRead(state *schedule.ScheduleState, day int) return occupied } -func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, includeCourse bool) []scheduleReadTaskOnDay { +type taskOnDay struct { + Task *schedule.ScheduleTask + SlotStart int + SlotEnd int +} + +type freeRange struct { + Day int + SlotStart int + SlotEnd int +} + +func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, includeCourse bool) []taskOnDay { if state == nil { return nil } - items := make([]scheduleReadTaskOnDay, 0) + items := make([]taskOnDay, 0) for i := range state.Tasks { task := &state.Tasks[i] if !includeCourse && isCourseScheduleTaskForRead(*task) { @@ -498,7 +555,7 @@ func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, inclu if slot.Day != day { continue } - items = append(items, scheduleReadTaskOnDay{ + items = append(items, taskOnDay{ Task: task, SlotStart: slot.SlotStart, SlotEnd: slot.SlotEnd, @@ -517,9 +574,9 @@ func listScheduleTasksOnDayForRead(state *schedule.ScheduleState, day int, inclu return items } -func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, start int, end int, includeCourse bool) []scheduleReadTaskOnDay { +func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, start int, end int, includeCourse bool) []taskOnDay { items := listScheduleTasksOnDayForRead(state, day, includeCourse) - filtered := make([]scheduleReadTaskOnDay, 0, len(items)) + filtered := make([]taskOnDay, 0, len(items)) for _, item := range items { if item.SlotStart <= end && item.SlotEnd >= start { filtered = append(filtered, item) @@ -528,7 +585,7 @@ func listScheduleTasksInRangeForRead(state *schedule.ScheduleState, day int, sta return filtered } -func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) []scheduleReadFreeRange { +func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) []freeRange { if state == nil { return nil } @@ -550,7 +607,7 @@ func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) } } - ranges := make([]scheduleReadFreeRange, 0) + ranges := make([]freeRange, 0) start := 0 for section := 1; section <= 12; section++ { if !occupied[section] { @@ -560,12 +617,12 @@ func findScheduleFreeRangesOnDayForRead(state *schedule.ScheduleState, day int) continue } if start > 0 { - ranges = append(ranges, scheduleReadFreeRange{Day: day, SlotStart: start, SlotEnd: section - 1}) + ranges = append(ranges, freeRange{Day: day, SlotStart: start, SlotEnd: section - 1}) start = 0 } } if start > 0 { - ranges = append(ranges, scheduleReadFreeRange{Day: day, SlotStart: start, SlotEnd: 12}) + ranges = append(ranges, freeRange{Day: day, SlotStart: start, SlotEnd: 12}) } return ranges } @@ -622,3 +679,118 @@ func fallbackText(text string, fallback string) string { } return strings.TrimSpace(text) } + +func parseObservationJSON(text string) (map[string]any, bool) { + trimmed := strings.TrimSpace(text) + if trimmed == "" || !strings.HasPrefix(trimmed, "{") { + return nil, false + } + var payload map[string]any + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return nil, false + } + return payload, true +} + +func readStringFromMap(payload map[string]any, keys ...string) (string, bool) { + if len(payload) == 0 { + return "", false + } + for _, key := range keys { + raw, ok := payload[key] + if !ok { + continue + } + value, ok := raw.(string) + if ok { + return value, true + } + } + return "", false +} + +func cloneSectionList(sections []map[string]any) []map[string]any { + if len(sections) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(sections)) + for _, section := range sections { + out = append(out, cloneAnyMap(section)) + } + return out +} + +func cloneAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = cloneAnyValue(value) + } + return out +} + +func cloneAnyValue(value any) any { + switch current := value.(type) { + case map[string]any: + return cloneAnyMap(current) + case []map[string]any: + out := make([]map[string]any, 0, len(current)) + for _, item := range current { + out = append(out, cloneAnyMap(item)) + } + return out + case []any: + out := make([]any, 0, len(current)) + for _, item := range current { + out = append(out, cloneAnyValue(item)) + } + return out + case []string: + out := make([]string, len(current)) + copy(out, current) + return out + case []int: + out := make([]int, len(current)) + copy(out, current) + return out + default: + return current + } +} + +func maxInt(values ...int) int { + if len(values) == 0 { + return 0 + } + best := values[0] + for _, value := range values[1:] { + if value > best { + best = value + } + } + return best +} + +func toInt(value any) (int, bool) { + switch current := value.(type) { + case int: + return current, true + case int32: + return int(current), true + case int64: + return int(current), true + case float64: + return int(current), true + default: + return 0, false + } +} + +func optionalIntValue(value *int) any { + if value == nil { + return nil + } + return *value +} diff --git a/backend/newAgent/tools/schedule_read/overview_queue.go b/backend/newAgent/tools/schedule_read/overview_queue.go new file mode 100644 index 0000000..9a766c4 --- /dev/null +++ b/backend/newAgent/tools/schedule_read/overview_queue.go @@ -0,0 +1,392 @@ +package schedule_read + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// BuildOverviewView 构造 get_overview 的纯展示视图。 +func BuildOverviewView(input OverviewViewInput) ReadResultView { + if input.State == nil { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "get_overview", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + totalSlots := input.State.Window.TotalDays * 12 + totalOccupied := 0 + taskExistingCount := 0 + taskSuggestedCount := 0 + taskPendingCount := 0 + courseExistingCount := 0 + + for i := range input.State.Tasks { + task := input.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([]ItemView, 0, input.State.Window.TotalDays) + for day := 1; day <= input.State.Window.TotalDays; day++ { + totalDayOccupied := countScheduleDayOccupiedForRead(input.State, day) + taskDayOccupied := countScheduleDayTaskOccupiedForRead(input.State, day) + taskEntries := listScheduleTasksOnDayForRead(input.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, BuildItem( + formatScheduleDayCN(input.State, day), + fmt.Sprintf("总占用 %d/12 节,任务占用 %d/12 节", totalDayOccupied, taskDayOccupied), + []string{fmt.Sprintf("任务 %d 项", len(taskEntries))}, + detailLines, + map[string]any{"day": day}, + )) + } + + taskItems := make([]ItemView, 0, len(input.State.Tasks)) + for i := range input.State.Tasks { + task := input.State.Tasks[i] + if isCourseScheduleTaskForRead(task) { + continue + } + detailLines := []string{ + "时段:" + formatScheduleTaskSlotsBriefCN(input.State, task.Slots), + "来源:" + formatScheduleTaskSourceCN(task), + } + if task.TaskClassID > 0 { + detailLines = append(detailLines, fmt.Sprintf("任务类 ID:%d", task.TaskClassID)) + } + taskItems = append(taskItems, BuildItem( + 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["task_id"]) + rightID, _ := toInt(taskItems[j].Meta["task_id"]) + return leftID < rightID + }) + + taskClassItems := make([]ItemView, 0, len(input.State.TaskClasses)) + for _, meta := range input.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, BuildItem( + 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{ + BuildKVSection("窗口概况", []KVField{ + BuildKVField("规划天数", fmt.Sprintf("%d 天", input.State.Window.TotalDays)), + BuildKVField("总时段", fmt.Sprintf("%d 节", totalSlots)), + BuildKVField("已占用", fmt.Sprintf("%d 节", totalOccupied)), + BuildKVField("空闲", fmt.Sprintf("%d 节", totalFree)), + BuildKVField("课程占位", fmt.Sprintf("%d 项", courseExistingCount)), + BuildKVField("已安排任务", fmt.Sprintf("%d 项", taskExistingCount)), + BuildKVField("已预排任务", fmt.Sprintf("%d 项", taskSuggestedCount)), + BuildKVField("待安排任务", fmt.Sprintf("%d 项", taskPendingCount)), + }), + BuildItemsSection("每日概况", dailyItems), + BuildItemsSection("任务清单", taskItems), + } + if len(taskClassItems) > 0 { + sections = append(sections, BuildItemsSection("任务类约束", taskClassItems)) + } + appendSectionIfPresent(§ions, BuildArgsSection("查询条件", input.ArgFields)) + + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: "当前排程总览", + Subtitle: fmt.Sprintf("%d 天窗口,已占用 %d/%d 节,待安排 %d 项。", input.State.Window.TotalDays, totalOccupied, totalSlots, taskPendingCount), + Metrics: []MetricField{ + BuildMetric("已占用", fmt.Sprintf("%d 节", totalOccupied)), + BuildMetric("空闲", fmt.Sprintf("%d 节", totalFree)), + BuildMetric("待安排", fmt.Sprintf("%d 项", taskPendingCount)), + BuildMetric("课程占位", fmt.Sprintf("%d 项", courseExistingCount)), + }, + Items: dailyItems, + Sections: sections, + Observation: input.Observation, + MachinePayload: map[string]any{ + "total_days": input.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, + }, + }) +} + +// BuildQueueStatusView 构造 queue_status 的纯展示视图。 +func BuildQueueStatusView(input QueueStatusViewInput) ReadResultView { + if input.State == nil { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "queue_status", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + payload, machinePayload, ok := DecodeQueueStatusPayload(input.Observation) + if !ok { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "queue_status", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + items := make([]ItemView, 0, 1+len(payload.NextTaskIDs)) + sections := make([]map[string]any, 0, 4) + if payload.Current != nil { + currentItem := buildQueueCurrentItem(input.State, payload.Current, payload.CurrentAttempt) + items = append(items, currentItem) + sections = append(sections, BuildItemsSection("当前处理", []ItemView{currentItem})) + } + + nextItems := make([]ItemView, 0, len(payload.NextTaskIDs)) + for index, taskID := range payload.NextTaskIDs { + nextItems = append(nextItems, buildQueuePendingItem(input.State, taskID, index)) + } + items = append(items, nextItems...) + if len(nextItems) > 0 { + sections = append(sections, BuildItemsSection("待处理队列", nextItems)) + } + + sections = append(sections, BuildKVSection("运行概况", []KVField{ + BuildKVField("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), + BuildKVField("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), + BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount)), + BuildKVField("当前任务", resolveTaskQueueLabelByID(input.State, payload.CurrentTaskID)), + })) + if strings.TrimSpace(payload.LastError) != "" { + sections = append(sections, BuildCalloutSection( + "最近一次失败", + "队列中保留了上一轮 apply 的失败原因。", + "warning", + []string{strings.TrimSpace(payload.LastError)}, + )) + } + appendSectionIfPresent(§ions, BuildArgsSection("查询条件", input.ArgFields)) + + title := fmt.Sprintf("队列待处理 %d 项", payload.PendingCount) + if payload.PendingCount == 0 && payload.CurrentTaskID == 0 { + title = "当前队列为空" + } + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: title, + Subtitle: buildQueueStatusSubtitle(payload), + Metrics: []MetricField{BuildMetric("待处理", fmt.Sprintf("%d 项", payload.PendingCount)), BuildMetric("已完成", fmt.Sprintf("%d 项", payload.CompletedCount)), BuildMetric("已跳过", fmt.Sprintf("%d 项", payload.SkippedCount))}, + Items: items, + Sections: sections, + Observation: input.Observation, + MachinePayload: machinePayload, + }) +} + +// DecodeQueueStatusPayload 解析 queue_status 的 JSON observation。 +func DecodeQueueStatusPayload(observation string) (QueueStatusPayload, map[string]any, bool) { + var payload QueueStatusPayload + trimmed := strings.TrimSpace(observation) + if trimmed == "" { + return payload, nil, false + } + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return payload, nil, false + } + raw, ok := parseObservationJSON(trimmed) + return payload, raw, ok +} + +func buildQueueStatusSubtitle(payload QueueStatusPayload) 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 "没有待处理任务,也没有正在处理的任务。" +} + +// 1. 这里没有强抽成通用 task builder,因为 queue_status 既要兼容 payload 快照, +// 2. 也要兼容通过 state 按 task_id 兜底,两类输入结构不同,硬抽反而会增加适配噪音。 +func buildQueueCurrentItem(state *schedule.ScheduleState, payload *QueueTaskSnapshot, attempt int) ItemView { + detailLines := buildQueueCurrentDetailLines(state, payload) + detailLines = append(detailLines, fmt.Sprintf("当前尝试:第 %d 次", maxInt(attempt, 1))) + return BuildItem( + 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) ItemView { + task := state.TaskByStateID(taskID) + if task == nil { + return BuildItem( + fmt.Sprintf("[%d]任务", taskID), + fmt.Sprintf("队列第 %d 位", index+1), + []string{"待处理"}, + []string{"当前状态快照中未找到更多任务详情。"}, + map[string]any{"task_id": taskID, "queue_index": index}, + ) + } + return BuildItem( + 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 := make([]string, 0, 2) + 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 *QueueTaskSnapshot) string { + if payload == nil { + return "当前处理" + } + return fmt.Sprintf("%s,%s", fallbackText(payload.Category, "未分类"), formatTargetTaskStatusCN(payload.Status)) +} + +func buildQueueCurrentDetailLines(state *schedule.ScheduleState, payload *QueueTaskSnapshot) []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/slots.go b/backend/newAgent/tools/schedule_read/slots.go new file mode 100644 index 0000000..6bfc344 --- /dev/null +++ b/backend/newAgent/tools/schedule_read/slots.go @@ -0,0 +1,427 @@ +package schedule_read + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// BuildAvailableSlotsView 构造 query_available_slots 的纯展示视图。 +func BuildAvailableSlotsView(input AvailableSlotsViewInput) ReadResultView { + payload, machinePayload, ok := DecodeAvailableSlotsPayload(input.Observation) + if !ok || !payload.Success { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "query_available_slots", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + items := make([]ItemView, 0, len(payload.Slots)) + for _, slot := range payload.Slots { + tags := []string{ + fmt.Sprintf("第%d周", slot.Week), + formatScheduleWeekdayCN(slot.DayOfWeek), + formatSlotTypeLabelCN(slot.SlotType), + } + detailLines := []string{ + fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd)), + fmt.Sprintf("跨度:%d 节", slot.SlotEnd-slot.SlotStart+1), + } + if strings.Contains(strings.ToLower(strings.TrimSpace(slot.SlotType)), "embed") { + if host := findScheduleHostTaskBySlotForRead(input.State, slot.Day, slot.SlotStart); host != nil { + detailLines = append(detailLines, fmt.Sprintf( + "宿主:[%d]%s,%s", + host.StateID, + fallbackText(host.Name, "未命名任务"), + formatScheduleTaskStatusCN(*host), + )) + } + } + items = append(items, BuildItem( + formatScheduleDaySlotCN(input.State, slot.Day, slot.SlotStart, slot.SlotEnd), + formatSlotTypeLabelCN(slot.SlotType), + tags, + detailLines, + map[string]any{ + "day": slot.Day, + "week": slot.Week, + "day_of_week": slot.DayOfWeek, + "slot_start": slot.SlotStart, + "slot_end": slot.SlotEnd, + "slot_type": slot.SlotType, + }, + )) + } + + metrics := []MetricField{ + BuildMetric("候选时段", fmt.Sprintf("%d 个", payload.Count)), + BuildMetric("纯空位", fmt.Sprintf("%d 个", payload.StrictCount)), + } + if payload.AllowEmbed { + metrics = append(metrics, BuildMetric("可嵌入候选", fmt.Sprintf("%d 个", payload.EmbeddedCount))) + } + + sections := []map[string]any{ + BuildKVSection("查询概况", []KVField{ + BuildKVField("查询跨度", fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1))), + BuildKVField("日期范围", formatDayScopeLabelCN(payload.DayScope)), + BuildKVField("星期过滤", formatWeekdayListCN(payload.DayOfWeek)), + BuildKVField("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)), + BuildKVField("允许嵌入补位", formatBoolLabelCN(payload.AllowEmbed)), + BuildKVField("排除节次", formatScheduleSectionListCN(payload.ExcludeSections)), + }), + } + appendSectionIfPresent(§ions, BuildArgsSection("筛选条件", input.ArgFields)) + if len(items) > 0 { + sections = append(sections, BuildItemsSection("候选时段", items)) + } else { + sections = append(sections, BuildCalloutSection( + "没有找到可用时段", + "当前筛选条件下没有命中的候选落点。", + "info", + []string{"可以调整周次、星期、节次范围,或修改是否允许嵌入补位。"}, + )) + } + + title := fmt.Sprintf("找到 %d 个可用时段", payload.Count) + if payload.Count == 0 { + title = "未找到可用时段" + } + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: title, + Subtitle: buildAvailableSlotsSubtitle(payload), + Metrics: metrics, + Items: items, + Sections: sections, + Observation: input.Observation, + MachinePayload: machinePayload, + }) +} + +// BuildRangeView 根据是否传入 slot_start / slot_end 选择整天或指定范围视图。 +func BuildRangeView(input RangeViewInput) ReadResultView { + if input.State == nil { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "query_range", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + if input.SlotStart == nil || input.SlotEnd == nil { + return BuildRangeFullDayView(RangeFullDayViewInput{ + State: input.State, + Observation: input.Observation, + Day: input.Day, + ArgFields: input.ArgFields, + }) + } + return BuildRangeSpecificView(RangeSpecificViewInput{ + State: input.State, + Observation: input.Observation, + Day: input.Day, + SlotStart: *input.SlotStart, + SlotEnd: *input.SlotEnd, + ArgFields: input.ArgFields, + }) +} + +// BuildRangeFullDayView 构造 query_range 整天模式视图。 +func BuildRangeFullDayView(input RangeFullDayViewInput) ReadResultView { + if input.State == nil { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "query_range", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + totalOccupied := countScheduleDayOccupiedForRead(input.State, input.Day) + taskOccupied := countScheduleDayTaskOccupiedForRead(input.State, input.Day) + freeRanges := findScheduleFreeRangesOnDayForRead(input.State, input.Day) + + bandItems := make([]ItemView, 0, 6) + for start := 1; start <= 11; start += 2 { + end := start + 1 + occupants := listScheduleTasksInRangeForRead(input.State, input.Day, start, end, true) + detailLines := make([]string, 0, len(occupants)) + for _, occupant := range occupants { + detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task)) + } + subtitle := "空闲" + tags := []string{"2 节"} + if len(occupants) > 0 { + subtitle = fmt.Sprintf("%d 个事项", len(occupants)) + tags = append(tags, "已占用") + } else { + tags = append(tags, "空闲") + detailLines = append(detailLines, "这一段当前可直接安排任务。") + } + bandItems = append(bandItems, BuildItem( + formatScheduleSlotRangeCN(start, end), + subtitle, + tags, + detailLines, + map[string]any{ + "day": input.Day, + "slot_start": start, + "slot_end": end, + }, + )) + } + + freeItems := make([]ItemView, 0, len(freeRanges)) + for _, freeRange := range freeRanges { + freeItems = append(freeItems, BuildItem( + formatScheduleSlotRangeCN(freeRange.SlotStart, freeRange.SlotEnd), + fmt.Sprintf("%d 节连续空闲", freeRange.SlotEnd-freeRange.SlotStart+1), + []string{"连续空闲"}, + []string{fmt.Sprintf("位置:%s", formatScheduleDaySlotCN(input.State, input.Day, freeRange.SlotStart, freeRange.SlotEnd))}, + map[string]any{ + "day": input.Day, + "slot_start": freeRange.SlotStart, + "slot_end": freeRange.SlotEnd, + }, + )) + } + + taskEntries := listScheduleTasksOnDayForRead(input.State, input.Day, false) + taskItems := make([]ItemView, 0, len(taskEntries)) + for _, entry := range taskEntries { + taskItems = append(taskItems, BuildItem( + fmt.Sprintf("[%d]%s", entry.Task.StateID, fallbackText(entry.Task.Name, "未命名任务")), + formatScheduleTaskStatusCN(*entry.Task), + []string{fallbackText(entry.Task.Category, "未分类")}, + []string{ + fmt.Sprintf("时段:%s", formatScheduleSlotRangeCN(entry.SlotStart, entry.SlotEnd)), + fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*entry.Task)), + }, + map[string]any{ + "task_id": entry.Task.StateID, + "slot_start": entry.SlotStart, + "slot_end": entry.SlotEnd, + "task_status": entry.Task.Status, + }, + )) + } + + sections := []map[string]any{ + BuildKVSection("当日概况", []KVField{ + BuildKVField("总占用", fmt.Sprintf("%d/12 节", totalOccupied)), + BuildKVField("任务占用", fmt.Sprintf("%d/12 节", taskOccupied)), + BuildKVField("连续空闲段", fmt.Sprintf("%d 段", len(freeRanges))), + }), + BuildItemsSection("时段分布", bandItems), + } + if len(freeItems) > 0 { + sections = append(sections, BuildItemsSection("连续空闲区", freeItems)) + } + if embeddableItems := buildEmbeddableItemsForDay(input.State, input.Day); len(embeddableItems) > 0 { + sections = append(sections, BuildItemsSection("可嵌入时段", embeddableItems)) + } + if len(taskItems) > 0 { + sections = append(sections, BuildItemsSection("当日任务", taskItems)) + } + appendSectionIfPresent(§ions, BuildArgsSection("查询条件", input.ArgFields)) + + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: fmt.Sprintf("%s全日概况", formatScheduleDayCN(input.State, input.Day)), + Subtitle: fmt.Sprintf("已占用 %d/12 节,连续空闲 %d 段。", totalOccupied, len(freeRanges)), + Metrics: []MetricField{ + BuildMetric("总占用", fmt.Sprintf("%d/12", totalOccupied)), + BuildMetric("任务占用", fmt.Sprintf("%d/12", taskOccupied)), + BuildMetric("空闲段", fmt.Sprintf("%d 段", len(freeRanges))), + }, + Items: bandItems, + Sections: sections, + Observation: input.Observation, + MachinePayload: map[string]any{ + "mode": "full_day", + "day": input.Day, + "occupied_slots": totalOccupied, + "task_occupied_slots": taskOccupied, + "free_range_count": len(freeRanges), + }, + }) +} + +// BuildRangeSpecificView 构造 query_range 指定范围模式视图。 +func BuildRangeSpecificView(input RangeSpecificViewInput) ReadResultView { + if input.State == nil { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "query_range", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + total := input.SlotEnd - input.SlotStart + 1 + freeCount := 0 + slotItems := make([]ItemView, 0, total) + for section := input.SlotStart; section <= input.SlotEnd; section++ { + occupants := listScheduleTasksInRangeForRead(input.State, input.Day, section, section, true) + detailLines := make([]string, 0, len(occupants)) + for _, occupant := range occupants { + detailLines = append(detailLines, buildRangeOccupantLine(*occupant.Task)) + } + subtitle := "空闲" + tags := []string{"空闲"} + if len(occupants) > 0 { + subtitle = fmt.Sprintf("%d 个事项", len(occupants)) + tags = []string{"已占用"} + } else { + freeCount++ + detailLines = append(detailLines, "这一节当前为空。") + } + slotItems = append(slotItems, BuildItem( + fmt.Sprintf("第%d节", section), + subtitle, + tags, + detailLines, + map[string]any{ + "day": input.Day, + "slot_start": section, + "slot_end": section, + }, + )) + } + + seen := make(map[int]struct{}) + rangeTaskItems := make([]ItemView, 0) + for _, occupant := range listScheduleTasksInRangeForRead(input.State, input.Day, input.SlotStart, input.SlotEnd, true) { + if _, exists := seen[occupant.Task.StateID]; exists { + continue + } + seen[occupant.Task.StateID] = struct{}{} + rangeTaskItems = append(rangeTaskItems, BuildItem( + fmt.Sprintf("[%d]%s", occupant.Task.StateID, fallbackText(occupant.Task.Name, "未命名任务")), + formatScheduleTaskStatusCN(*occupant.Task), + []string{fallbackText(occupant.Task.Category, "未分类")}, + []string{ + fmt.Sprintf("覆盖范围:%s", formatScheduleDaySlotCN(input.State, input.Day, occupant.SlotStart, occupant.SlotEnd)), + fmt.Sprintf("来源:%s", formatScheduleTaskSourceCN(*occupant.Task)), + }, + map[string]any{ + "task_id": occupant.Task.StateID, + "slot_start": occupant.SlotStart, + "slot_end": occupant.SlotEnd, + "task_status": occupant.Task.Status, + }, + )) + } + + sections := []map[string]any{ + BuildKVSection("范围概况", []KVField{ + BuildKVField("查询范围", formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)), + BuildKVField("总节数", fmt.Sprintf("%d 节", total)), + BuildKVField("空闲节数", fmt.Sprintf("%d 节", freeCount)), + BuildKVField("占用节数", fmt.Sprintf("%d 节", total-freeCount)), + }), + BuildItemsSection("逐节情况", slotItems), + } + if len(rangeTaskItems) > 0 { + sections = append(sections, BuildItemsSection("范围内事项", rangeTaskItems)) + } + appendSectionIfPresent(§ions, BuildArgsSection("查询条件", input.ArgFields)) + + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: fmt.Sprintf("%s %s", formatScheduleDayCN(input.State, input.Day), formatScheduleSlotRangeCN(input.SlotStart, input.SlotEnd)), + Subtitle: fmt.Sprintf("共 %d 节,空闲 %d 节,占用 %d 节。", total, freeCount, total-freeCount), + Metrics: []MetricField{ + BuildMetric("总节数", fmt.Sprintf("%d 节", total)), + BuildMetric("空闲", fmt.Sprintf("%d 节", freeCount)), + BuildMetric("事项", fmt.Sprintf("%d 个", len(rangeTaskItems))), + }, + Items: slotItems, + Sections: sections, + Observation: input.Observation, + MachinePayload: map[string]any{ + "mode": "specific_range", + "day": input.Day, + "slot_start": input.SlotStart, + "slot_end": input.SlotEnd, + "free_count": freeCount, + "occupied_count": total - freeCount, + }, + }) +} + +// DecodeAvailableSlotsPayload 解析 query_available_slots 的 JSON observation。 +func DecodeAvailableSlotsPayload(observation string) (AvailableSlotsPayload, map[string]any, bool) { + var payload AvailableSlotsPayload + trimmed := strings.TrimSpace(observation) + if trimmed == "" { + return payload, nil, false + } + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return payload, nil, false + } + raw, ok := parseObservationJSON(trimmed) + return payload, raw, ok +} + +func buildAvailableSlotsSubtitle(payload AvailableSlotsPayload) string { + parts := []string{ + fmt.Sprintf("%d 节连续时段", maxInt(payload.Span, 1)), + formatDayScopeLabelCN(payload.DayScope), + buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter), + } + if len(payload.DayOfWeek) > 0 { + parts = append(parts, formatWeekdayListCN(payload.DayOfWeek)) + } + if payload.AllowEmbed { + parts = append(parts, "允许补充可嵌入候选") + } else { + parts = append(parts, "仅查看纯空位") + } + return strings.Join(parts, ",") +} + +func buildEmbeddableItemsForDay(state *schedule.ScheduleState, day int) []ItemView { + if state == nil { + return nil + } + items := make([]ItemView, 0) + for i := range state.Tasks { + task := state.Tasks[i] + if !task.CanEmbed || task.EmbeddedBy != nil || task.EmbedHost != nil { + continue + } + for _, slot := range task.Slots { + if slot.Day != day { + continue + } + items = append(items, BuildItem( + formatScheduleSlotRangeCN(slot.SlotStart, slot.SlotEnd), + fmt.Sprintf("可嵌入到 [%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")), + []string{fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(task)}, + []string{ + fmt.Sprintf("宿主时段:%s", formatScheduleDaySlotCN(state, day, slot.SlotStart, slot.SlotEnd)), + "该时段允许放入更短的嵌入任务。", + }, + map[string]any{ + "host_task_id": task.StateID, + "day": day, + "slot_start": slot.SlotStart, + "slot_end": slot.SlotEnd, + }, + )) + } + } + return items +} + +func buildRangeOccupantLine(task schedule.ScheduleTask) string { + return fmt.Sprintf( + "[%d]%s,%s,%s", + task.StateID, + fallbackText(task.Name, "未命名任务"), + formatScheduleTaskStatusCN(task), + fallbackText(task.Category, "未分类"), + ) +} diff --git a/backend/newAgent/tools/schedule_read/tasks.go b/backend/newAgent/tools/schedule_read/tasks.go new file mode 100644 index 0000000..7bcd85a --- /dev/null +++ b/backend/newAgent/tools/schedule_read/tasks.go @@ -0,0 +1,301 @@ +package schedule_read + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// BuildTargetTasksView 构造 query_target_tasks 的纯展示视图。 +func BuildTargetTasksView(input TargetTasksViewInput) ReadResultView { + payload, machinePayload, ok := DecodeTargetTasksPayload(input.Observation) + if !ok || !payload.Success { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "query_target_tasks", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + items := make([]ItemView, 0, len(payload.Items)) + for _, item := range payload.Items { + items = append(items, BuildItem( + fmt.Sprintf("[%d]%s", item.TaskID, fallbackText(item.Name, "未命名任务")), + buildTargetTaskSubtitle(item), + buildTargetTaskTags(item), + buildTargetTaskDetailLines(input.State, item), + map[string]any{ + "task_id": item.TaskID, + "category": item.Category, + "status": item.Status, + "duration": item.Duration, + "task_class_id": item.TaskClassID, + }, + )) + } + + metrics := []MetricField{ + BuildMetric("候选任务", fmt.Sprintf("%d 项", payload.Count)), + BuildMetric("任务池", formatTargetPoolStatusCN(payload.Status)), + } + if payload.Enqueue { + metrics = append(metrics, BuildMetric("已入队", fmt.Sprintf("%d 项", payload.Enqueued))) + } + + sections := []map[string]any{ + BuildKVSection("筛选概况", []KVField{ + BuildKVField("任务池", formatTargetPoolStatusCN(payload.Status)), + BuildKVField("日期范围", formatDayScopeLabelCN(payload.DayScope)), + BuildKVField("星期过滤", formatWeekdayListCN(payload.DayOfWeek)), + BuildKVField("周次范围", buildWeekRangeLabelCN(payload.WeekFrom, payload.WeekTo, payload.WeekFilter)), + BuildKVField("是否入队", formatBoolLabelCN(payload.Enqueue)), + }), + } + appendSectionIfPresent(§ions, BuildArgsSection("筛选条件", input.ArgFields)) + if payload.Queue != nil { + sections = append(sections, BuildKVSection("队列状态", []KVField{ + BuildKVField("待处理", fmt.Sprintf("%d 项", payload.Queue.PendingCount)), + BuildKVField("已完成", fmt.Sprintf("%d 项", payload.Queue.CompletedCount)), + BuildKVField("已跳过", fmt.Sprintf("%d 项", payload.Queue.SkippedCount)), + BuildKVField("当前任务", resolveTaskQueueLabelByID(input.State, payload.Queue.CurrentTaskID)), + })) + } + if len(items) > 0 { + sections = append(sections, BuildItemsSection("候选任务", items)) + } else { + sections = append(sections, BuildCalloutSection( + "没有命中任务", + "当前筛选条件下没有找到候选任务。", + "info", + []string{"可以放宽状态、日期或任务 ID 过滤条件后再试。"}, + )) + } + + title := fmt.Sprintf("找到 %d 个候选任务", payload.Count) + if payload.Count == 0 { + title = "未找到候选任务" + } + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: title, + Subtitle: buildTargetTasksSummarySubtitle(payload), + Metrics: metrics, + Items: items, + Sections: sections, + Observation: input.Observation, + MachinePayload: machinePayload, + }) +} + +// BuildTaskInfoView 构造 get_task_info 的纯展示视图。 +func BuildTaskInfoView(input TaskInfoViewInput) ReadResultView { + if input.State == nil { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "get_task_info", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + task := input.State.TaskByStateID(input.TaskID) + if task == nil { + return BuildFailureView(BuildFailureViewInput{ + ToolName: "get_task_info", + Observation: input.Observation, + ArgFields: input.ArgFields, + }) + } + + slotItems := make([]ItemView, 0, len(task.Slots)) + for _, slot := range cloneAndSortTaskSlots(task.Slots) { + slotItems = append(slotItems, BuildItem( + formatScheduleDaySlotCN(input.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 := []KVField{ + BuildKVField("类别", fallbackText(task.Category, "未分类")), + BuildKVField("状态", formatScheduleTaskStatusCN(*task)), + BuildKVField("来源", formatScheduleTaskSourceCN(*task)), + BuildKVField("落位情况", buildTaskPlacementLabel(task)), + BuildKVField("时长需求", buildTaskDurationLabel(task)), + } + if task.TaskClassID > 0 { + fields = append(fields, BuildKVField("任务类 ID", fmt.Sprintf("%d", task.TaskClassID))) + } + if task.CanEmbed { + fields = append(fields, BuildKVField("可作为宿主", "是")) + } + + sections := []map[string]any{ + BuildKVSection("基本信息", fields), + } + if len(slotItems) > 0 { + sections = append(sections, BuildItemsSection("占用时段", slotItems)) + } + if relationLines := buildTaskRelationLines(input.State, task); len(relationLines) > 0 { + sections = append(sections, BuildCalloutSection("嵌入关系", "当前任务存在宿主或宿体关系。", "info", relationLines)) + } + appendSectionIfPresent(§ions, BuildArgsSection("查询条件", input.ArgFields)) + + return BuildResultView(BuildResultViewInput{ + Status: StatusDone, + Title: fmt.Sprintf("[%d]%s", task.StateID, fallbackText(task.Name, "未命名任务")), + Subtitle: fmt.Sprintf("%s,%s", fallbackText(task.Category, "未分类"), formatScheduleTaskStatusCN(*task)), + Metrics: []MetricField{ + BuildMetric("状态", formatScheduleTaskStatusCN(*task)), + BuildMetric("时长", buildTaskDurationLabel(task)), + BuildMetric("落位", buildTaskPlacementLabel(task)), + }, + Items: slotItems, + Sections: sections, + Observation: input.Observation, + MachinePayload: map[string]any{ + "task_id": task.StateID, + "source": task.Source, + "status": task.Status, + "task_class_id": task.TaskClassID, + "can_embed": task.CanEmbed, + "embedded_by": optionalIntValue(task.EmbeddedBy), + "embed_host": optionalIntValue(task.EmbedHost), + }, + }) +} + +// DecodeTargetTasksPayload 解析 query_target_tasks 的 JSON observation。 +func DecodeTargetTasksPayload(observation string) (TargetTasksPayload, map[string]any, bool) { + var payload TargetTasksPayload + trimmed := strings.TrimSpace(observation) + if trimmed == "" { + return payload, nil, false + } + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return payload, nil, false + } + raw, ok := parseObservationJSON(trimmed) + return payload, raw, ok +} + +func buildTargetTasksSummarySubtitle(payload TargetTasksPayload) 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 TargetTaskRecord) string { + return fmt.Sprintf("%s,%s", fallbackText(item.Category, "未分类"), formatTargetTaskStatusCN(item.Status)) +} + +func buildTargetTaskTags(item TargetTaskRecord) []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 TargetTaskRecord) []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/schedule_read/types.go b/backend/newAgent/tools/schedule_read/types.go new file mode 100644 index 0000000..4c2b596 --- /dev/null +++ b/backend/newAgent/tools/schedule_read/types.go @@ -0,0 +1,312 @@ +package schedule_read + +import ( + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +const ( + // ViewTypeReadResult 固定为第二批 read 结果卡片的前端识别类型。 + ViewTypeReadResult = "schedule.read_result" + + // ViewVersionReadResult 固定为当前 read 结果结构版本。 + ViewVersionReadResult = 1 + + // 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。 + StatusDone = "done" + StatusFailed = "failed" + StatusBlocked = "blocked" +) + +// ReadResultView 是子包暴露给父包 adapter 的纯展示结构。 +// +// 职责边界: +// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据。 +// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议。 +// 3. collapsed / expanded 继续保留 map 形态,方便父包直接桥接到现有展示协议。 +type ReadResultView struct { + ViewType string `json:"view_type"` + Version int `json:"version"` + Collapsed map[string]any `json:"collapsed"` + Expanded map[string]any `json:"expanded"` +} + +// CollapsedView 表示折叠态卡片数据。 +type CollapsedView struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Status string `json:"status"` + StatusLabel string `json:"status_label"` + Metrics []MetricField `json:"metrics"` +} + +// ExpandedView 表示展开态卡片数据。 +type ExpandedView struct { + Items []ItemView `json:"items"` + Sections []map[string]any `json:"sections"` + RawText string `json:"raw_text"` + MachinePayload map[string]any `json:"machine_payload,omitempty"` +} + +// MetricField 是 collapsed.metrics 的轻量键值结构。 +type MetricField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// KVField 是展开态 kv section 的轻量键值结构。 +type KVField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// ItemView 是展开态 items 的通用结构。 +type ItemView struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Tags []string `json:"tags"` + DetailLines []string `json:"detail_lines"` + Meta map[string]any `json:"meta,omitempty"` +} + +// BuildResultViewInput 是通用 read 结果视图 builder 的输入。 +// +// 职责边界: +// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区。 +// 2. 不负责判断工具是否执行成功;调用方需要在进入这里前确定 status。 +// 3. observation 会原样写入 raw_text,不能在这里改写给 LLM 的观察文本语义。 +type BuildResultViewInput struct { + Status string + Title string + Subtitle string + Metrics []MetricField + Items []ItemView + Sections []map[string]any + Observation string + MachinePayload map[string]any +} + +// BuildFailureViewInput 是通用失败视图 builder 的输入。 +type BuildFailureViewInput struct { + ToolName string + Status string + Title string + Subtitle string + Observation string + ArgFields []KVField +} + +// AvailableSlotsViewInput 是 query_available_slots 视图构造输入。 +type AvailableSlotsViewInput struct { + State *schedule.ScheduleState + Observation string + ArgFields []KVField +} + +// RangeViewInput 是 query_range 统一入口输入。 +type RangeViewInput struct { + State *schedule.ScheduleState + Observation string + Day int + SlotStart *int + SlotEnd *int + ArgFields []KVField +} + +// RangeFullDayViewInput 是 query_range 整天模式输入。 +type RangeFullDayViewInput struct { + State *schedule.ScheduleState + Observation string + Day int + ArgFields []KVField +} + +// RangeSpecificViewInput 是 query_range 指定时段模式输入。 +type RangeSpecificViewInput struct { + State *schedule.ScheduleState + Observation string + Day int + SlotStart int + SlotEnd int + ArgFields []KVField +} + +// TargetTasksViewInput 是 query_target_tasks 视图构造输入。 +type TargetTasksViewInput struct { + State *schedule.ScheduleState + Observation string + ArgFields []KVField +} + +// TaskInfoViewInput 是 get_task_info 视图构造输入。 +type TaskInfoViewInput struct { + State *schedule.ScheduleState + Observation string + TaskID int + ArgFields []KVField +} + +// OverviewViewInput 是 get_overview 视图构造输入。 +type OverviewViewInput struct { + State *schedule.ScheduleState + Observation string + ArgFields []KVField +} + +// QueueStatusViewInput 是 queue_status 视图构造输入。 +type QueueStatusViewInput struct { + State *schedule.ScheduleState + Observation string + ArgFields []KVField +} + +// AvailableSlotsPayload 是 query_available_slots 的结构化结果。 +type AvailableSlotsPayload 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 []AvailableSlotRecord `json:"slots"` +} + +// AvailableSlotRecord 是 query_available_slots 单条时段记录。 +type AvailableSlotRecord 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"` +} + +// TargetTasksPayload 是 query_target_tasks 的结构化结果。 +type TargetTasksPayload 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 *TargetTasksQueueRecord `json:"queue"` + Items []TargetTaskRecord `json:"items"` +} + +// TargetTasksQueueRecord 是目标任务查询里的队列快照。 +type TargetTasksQueueRecord 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"` +} + +// TargetTaskRecord 是 query_target_tasks 单条任务记录。 +type TargetTaskRecord 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 []TargetTaskSlotInfo `json:"slots"` +} + +// TargetTaskSlotInfo 是目标任务时段信息。 +type TargetTaskSlotInfo 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"` +} + +// QueueStatusPayload 是 queue_status 的结构化结果。 +type QueueStatusPayload 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 *QueueTaskSnapshot `json:"current"` +} + +// QueueTaskSnapshot 是 queue_status 当前任务快照。 +type QueueTaskSnapshot 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 []TargetTaskSlotInfo `json:"slots"` +} + +func (view CollapsedView) Map() map[string]any { + metrics := make([]map[string]any, 0, len(view.Metrics)) + for _, metric := range view.Metrics { + metrics = append(metrics, map[string]any{ + "label": strings.TrimSpace(metric.Label), + "value": strings.TrimSpace(metric.Value), + }) + } + return map[string]any{ + "title": strings.TrimSpace(view.Title), + "subtitle": strings.TrimSpace(view.Subtitle), + "status": normalizeStatus(view.Status), + "status_label": strings.TrimSpace(view.StatusLabel), + "metrics": metrics, + } +} + +func (view ExpandedView) Map() map[string]any { + items := make([]map[string]any, 0, len(view.Items)) + for _, item := range view.Items { + items = append(items, item.Map()) + } + + out := map[string]any{ + "items": items, + "sections": cloneSectionList(view.Sections), + "raw_text": view.RawText, + } + if len(view.MachinePayload) > 0 { + out["machine_payload"] = cloneAnyMap(view.MachinePayload) + } + return out +} + +func (view ItemView) Map() map[string]any { + item := map[string]any{ + "title": strings.TrimSpace(view.Title), + "subtitle": strings.TrimSpace(view.Subtitle), + "tags": normalizeStringSlice(view.Tags), + "detail_lines": normalizeStringSlice(view.DetailLines), + } + if len(view.Meta) > 0 { + item["meta"] = cloneAnyMap(view.Meta) + } + return item +} diff --git a/backend/newAgent/tools/schedule_read_handlers.go b/backend/newAgent/tools/schedule_read_handlers.go new file mode 100644 index 0000000..e8f63f2 --- /dev/null +++ b/backend/newAgent/tools/schedule_read_handlers.go @@ -0,0 +1,345 @@ +package newagenttools + +import ( + "fmt" + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + scheduleread "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule_read" +) + +type scheduleReadObserveFunc func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) +type scheduleReadViewBuilder func(input scheduleReadAdapterInput) scheduleread.ReadResultView + +// scheduleReadAdapterInput 是父包传给 schedule_read 子包前的最小上下文。 +// +// 职责边界: +// 1. 只携带展示构造需要的 state、args、observation 与已本地化参数字段; +// 2. 不把 ToolExecutionResult / ToolArgumentView 传入子包,避免反向依赖父包; +// 3. ObservationText 必须原样来自底层 schedule 工具,不在 adapter 层改写。 +type scheduleReadAdapterInput struct { + ToolName string + Args map[string]any + State *schedule.ScheduleState + ObservationText string + ArgFields []scheduleread.KVField +} + +// NewQueryAvailableSlotsToolHandler 为 query_available_slots 生成结构化读结果。 +func NewQueryAvailableSlotsToolHandler() ToolHandler { + return newScheduleReadToolHandler( + "query_available_slots", + func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) { + return schedule.QueryAvailableSlots(state, args), nil + }, + func(input scheduleReadAdapterInput) scheduleread.ReadResultView { + return scheduleread.BuildAvailableSlotsView(scheduleread.AvailableSlotsViewInput{ + State: input.State, + Observation: input.ObservationText, + ArgFields: input.ArgFields, + }) + }, + ) +} + +// NewQueryRangeToolHandler 为 query_range 生成结构化读结果。 +func NewQueryRangeToolHandler() ToolHandler { + return newScheduleReadToolHandler( + "query_range", + func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) { + day, ok := schedule.ArgsInt(args, "day") + if !ok { + result := buildScheduleReadFailureResult("query_range", args, state, "查询失败:缺少必填参数 day。") + return "", &result + } + if state == nil { + result := buildScheduleReadFailureResult("query_range", args, nil, "查询失败:日程状态为空,无法读取时间范围。") + return "", &result + } + return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end")), nil + }, + func(input scheduleReadAdapterInput) scheduleread.ReadResultView { + day, _ := schedule.ArgsInt(input.Args, "day") + return scheduleread.BuildRangeView(scheduleread.RangeViewInput{ + State: input.State, + Observation: input.ObservationText, + Day: day, + SlotStart: schedule.ArgsIntPtr(input.Args, "slot_start"), + SlotEnd: schedule.ArgsIntPtr(input.Args, "slot_end"), + ArgFields: input.ArgFields, + }) + }, + ) +} + +// NewQueryTargetTasksToolHandler 为 query_target_tasks 生成结构化读结果。 +func NewQueryTargetTasksToolHandler() ToolHandler { + return newScheduleReadToolHandler( + "query_target_tasks", + func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) { + return schedule.QueryTargetTasks(state, args), nil + }, + func(input scheduleReadAdapterInput) scheduleread.ReadResultView { + return scheduleread.BuildTargetTasksView(scheduleread.TargetTasksViewInput{ + State: input.State, + Observation: input.ObservationText, + ArgFields: input.ArgFields, + }) + }, + ) +} + +// NewGetTaskInfoToolHandler 为 get_task_info 生成结构化读结果。 +func NewGetTaskInfoToolHandler() ToolHandler { + return newScheduleReadToolHandler( + "get_task_info", + func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) { + taskID, ok := schedule.ArgsInt(args, "task_id") + if !ok { + result := buildScheduleReadFailureResult("get_task_info", args, state, "查询失败:缺少必填参数 task_id。") + return "", &result + } + if state == nil { + result := buildScheduleReadFailureResult("get_task_info", args, nil, "查询失败:日程状态为空,无法读取任务详情。") + return "", &result + } + return schedule.GetTaskInfo(state, taskID), nil + }, + func(input scheduleReadAdapterInput) scheduleread.ReadResultView { + taskID, _ := schedule.ArgsInt(input.Args, "task_id") + return scheduleread.BuildTaskInfoView(scheduleread.TaskInfoViewInput{ + State: input.State, + Observation: input.ObservationText, + TaskID: taskID, + ArgFields: input.ArgFields, + }) + }, + ) +} + +// NewGetOverviewToolHandler 为 get_overview 生成结构化读结果。 +func NewGetOverviewToolHandler() ToolHandler { + return newScheduleReadToolHandler( + "get_overview", + func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) { + if state == nil { + result := buildScheduleReadFailureResult("get_overview", args, nil, "查看总览失败:日程状态为空,无法读取总览。") + return "", &result + } + return schedule.GetOverview(state), nil + }, + func(input scheduleReadAdapterInput) scheduleread.ReadResultView { + return scheduleread.BuildOverviewView(scheduleread.OverviewViewInput{ + State: input.State, + Observation: input.ObservationText, + ArgFields: input.ArgFields, + }) + }, + ) +} + +// NewQueueStatusToolHandler 为 queue_status 生成结构化读结果。 +func NewQueueStatusToolHandler() ToolHandler { + return newScheduleReadToolHandler( + "queue_status", + func(state *schedule.ScheduleState, args map[string]any) (string, *ToolExecutionResult) { + observation := schedule.QueueStatus(state, args) + if state == nil { + result := buildScheduleReadFailureResult("queue_status", args, nil, observation) + return "", &result + } + return observation, nil + }, + func(input scheduleReadAdapterInput) scheduleread.ReadResultView { + return scheduleread.BuildQueueStatusView(scheduleread.QueueStatusViewInput{ + State: input.State, + Observation: input.ObservationText, + ArgFields: input.ArgFields, + }) + }, + ) +} + +// newScheduleReadToolHandler 统一构造父包 read adapter。 +// +// 步骤化说明: +// 1. 先执行底层 schedule 工具,拿到原始 observation,保证 LLM 观察文本不变; +// 2. 再用 LegacyResultWithState 复用父包状态判断、参数中文展示与默认字段; +// 3. 最后调用 schedule_read 子包生成纯展示视图,并包回 ToolExecutionResult。 +func newScheduleReadToolHandler( + toolName string, + observe scheduleReadObserveFunc, + buildView scheduleReadViewBuilder, +) ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + observation, earlyResult := observe(state, args) + if earlyResult != nil { + return EnsureToolResultDefaults(*earlyResult, args) + } + + legacy := LegacyResultWithState(toolName, args, state, observation) + input := scheduleReadAdapterInput{ + ToolName: toolName, + Args: cloneAnyMap(args), + State: state, + ObservationText: observation, + ArgFields: extractScheduleReadArgumentFields(legacy.ArgumentView), + } + + view := buildView(input) + if normalizeToolStatus(legacy.Status) != ToolStatusDone { + view = scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{ + ToolName: toolName, + Status: legacy.Status, + Observation: observation, + ArgFields: input.ArgFields, + }) + } + return buildScheduleReadExecutionResult(legacy, args, view) + } +} + +// buildScheduleReadFailureResult 用于底层工具执行前即可确定失败的参数/状态场景。 +func buildScheduleReadFailureResult( + toolName string, + args map[string]any, + state *schedule.ScheduleState, + observation string, +) ToolExecutionResult { + legacy := LegacyResultWithState(toolName, args, state, observation) + view := scheduleread.BuildFailureView(scheduleread.BuildFailureViewInput{ + ToolName: toolName, + Status: ToolStatusFailed, + Observation: observation, + ArgFields: extractScheduleReadArgumentFields(legacy.ArgumentView), + }) + return buildScheduleReadExecutionResult(legacy, args, view) +} + +// buildScheduleReadExecutionResult 负责把子包纯展示视图包回父包统一协议。 +// +// 职责边界: +// 1. 只做 ReadResultView -> ToolDisplayView 的协议桥接; +// 2. 不改写 ObservationText,确保 execute / SSE / timeline 仍使用同一份 observation; +// 3. 错误码与错误文案继续复用父包既有 JSON / 文本解析逻辑。 +func buildScheduleReadExecutionResult( + legacy ToolExecutionResult, + args map[string]any, + view scheduleread.ReadResultView, +) ToolExecutionResult { + result := legacy + status := normalizeToolStatus(result.Status) + if status == "" { + status = ToolStatusDone + } + if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok { + if normalized := normalizeToolStatus(collapsedStatus); normalized != "" { + status = normalized + } + } + + collapsed := cloneAnyMap(view.Collapsed) + if collapsed == nil { + collapsed = make(map[string]any) + } + expanded := cloneAnyMap(view.Expanded) + if expanded == nil { + expanded = make(map[string]any) + } + + collapsed["status"] = status + if _, exists := collapsed["status_label"]; !exists { + collapsed["status_label"] = resolveToolStatusLabelCN(status) + } + if _, exists := expanded["raw_text"]; !exists { + expanded["raw_text"] = strings.TrimSpace(result.ObservationText) + } + + viewType := strings.TrimSpace(view.ViewType) + if viewType == "" { + viewType = scheduleread.ViewTypeReadResult + } + version := view.Version + if version <= 0 { + version = scheduleread.ViewVersionReadResult + } + + result.Status = status + result.Success = status == ToolStatusDone + result.ResultView = &ToolDisplayView{ + ViewType: viewType, + Version: version, + Collapsed: collapsed, + Expanded: expanded, + } + if title, ok := readStringAnyMap(collapsed, "title"); ok { + result.Summary = title + } + if !result.Success { + errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status) + if strings.TrimSpace(result.ErrorCode) == "" { + result.ErrorCode = strings.TrimSpace(errorCode) + } + if strings.TrimSpace(result.ErrorMessage) == "" { + result.ErrorMessage = strings.TrimSpace(errorMessage) + } + } + return EnsureToolResultDefaults(result, args) +} + +// extractScheduleReadArgumentFields 把父包 ToolArgumentView 投影成子包可消费的 KVField。 +func extractScheduleReadArgumentFields(view *ToolArgumentView) []scheduleread.KVField { + if view == nil || view.Expanded == nil { + return make([]scheduleread.KVField, 0) + } + rawFields, exists := view.Expanded["fields"] + if !exists { + return make([]scheduleread.KVField, 0) + } + + fields := make([]scheduleread.KVField, 0) + appendField := func(row map[string]any) { + label, _ := row["label"].(string) + display, _ := row["display"].(string) + label = strings.TrimSpace(label) + display = strings.TrimSpace(display) + if label == "" || display == "" { + return + } + fields = append(fields, scheduleread.BuildKVField(label, display)) + } + + switch typed := rawFields.(type) { + case []map[string]any: + for _, row := range typed { + appendField(row) + } + case []any: + for _, item := range typed { + row, ok := item.(map[string]any) + if !ok { + continue + } + appendField(row) + } + } + if len(fields) == 0 { + return make([]scheduleread.KVField, 0) + } + return fields +} + +func readStringAnyMap(payload map[string]any, key string) (string, bool) { + if len(payload) == 0 { + return "", false + } + raw, exists := payload[key] + if !exists || raw == nil { + return "", false + } + text := strings.TrimSpace(fmt.Sprintf("%v", raw)) + if text == "" || text == "" { + return "", false + } + return text, true +} diff --git a/backend/newAgent/tools/schedule_read_overview_queue_handlers.go b/backend/newAgent/tools/schedule_read_overview_queue_handlers.go deleted file mode 100644 index c1e7248..0000000 --- a/backend/newAgent/tools/schedule_read_overview_queue_handlers.go +++ /dev/null @@ -1,383 +0,0 @@ -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_types.go b/backend/newAgent/tools/schedule_read_result_types.go deleted file mode 100644 index c96f1b9..0000000 --- a/backend/newAgent/tools/schedule_read_result_types.go +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 98fe524..0000000 --- a/backend/newAgent/tools/schedule_read_slots_handlers.go +++ /dev/null @@ -1,409 +0,0 @@ -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 deleted file mode 100644 index ec6dcdc..0000000 --- a/backend/newAgent/tools/schedule_read_tasks_handlers.go +++ /dev/null @@ -1,300 +0,0 @@ -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/task_class_write.go b/backend/newAgent/tools/task_class_write.go index 32a2d53..fa0c01b 100644 --- a/backend/newAgent/tools/task_class_write.go +++ b/backend/newAgent/tools/task_class_write.go @@ -7,7 +7,6 @@ import ( "time" "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" ) // TaskClassUpsertInput 描述任务类写库工具的标准化入参。 @@ -52,91 +51,6 @@ type taskClassUpsertToolResult struct { ErrorCode string `json:"error_code"` } -// NewTaskClassUpsertToolHandler 创建 upsert_task_class 工具 handler。 -// -// 职责边界: -// 1. 只做参数解析、合法性校验、调用依赖、返回统一 JSON; -// 2. 不负责草稿生成,草稿由 prompt+LLM 完成; -// 3. 不依赖 ScheduleState,可在纯聊天场景调用(execute 会注入 _user_id)。 -func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler { - return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { - _ = state - - if deps.UpsertTaskClass == nil { - return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ - Tool: "upsert_task_class", - Success: false, - Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}}, - Error: "任务类写库依赖未注入", - ErrorCode: "dependency_missing", - })) - } - - userID, ok := readUpsertUserID(args["_user_id"]) - if !ok || userID <= 0 { - return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ - Tool: "upsert_task_class", - Success: false, - Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}}, - Error: "工具调用失败:无法识别用户身份", - ErrorCode: "missing_user_id", - })) - } - - input, parseErr := parseTaskClassUpsertInput(args) - if parseErr != nil { - return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ - Tool: "upsert_task_class", - Success: false, - Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}}, - Error: parseErr.Error(), - ErrorCode: "invalid_args", - })) - } - - issues := validateTaskClassUpsertRequest(input.Request, input.ID) - if len(issues) > 0 { - return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ - Tool: "upsert_task_class", - Success: false, - Validation: taskClassValidationResult{OK: false, Issues: issues}, - Error: strings.Join(issues, ";"), - ErrorCode: "validation_failed", - })) - } - - result, err := deps.UpsertTaskClass(userID, input) - if err != nil { - return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ - Tool: "upsert_task_class", - Success: false, - Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}}, - Error: err.Error(), - ErrorCode: "persist_failed", - })) - } - if result.TaskClassID <= 0 { - return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ - Tool: "upsert_task_class", - Success: false, - Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}}, - Error: "写入后未返回有效 task_class_id", - ErrorCode: "invalid_persist_result", - })) - } - - return LegacyResult("upsert_task_class", args, marshalTaskClassUpsertResult(taskClassUpsertToolResult{ - Tool: "upsert_task_class", - Success: true, - TaskClassID: result.TaskClassID, - Created: result.Created, - Validation: taskClassValidationResult{OK: true, Issues: []string{}}, - Error: "", - ErrorCode: "", - })) - } -} - func parseTaskClassUpsertInput(args map[string]any) (TaskClassUpsertInput, error) { id := 0 if rawID, exists := args["id"]; exists { diff --git a/backend/newAgent/tools/taskclass_result/common.go b/backend/newAgent/tools/taskclass_result/common.go new file mode 100644 index 0000000..d855a86 --- /dev/null +++ b/backend/newAgent/tools/taskclass_result/common.go @@ -0,0 +1,397 @@ +package taskclass_result + +import ( + "fmt" + "strings" +) + +// 说明: +// 1. schedule_read / schedule_analysis 已经各自带有一套卡片 helper; +// 2. 这一轮只迁 taskclass 写入结果,如果现在强行把前三批 helper 回抽成公共层,会扩大回归面; +// 3. 因此本包只保留 taskclass.write_result 所需的最小 helper,待非 schedule 主链稳定后再统一评估抽象。 + +func buildWriteResultView( + status string, + title string, + subtitle string, + metrics []MetricField, + items []ItemView, + sections []map[string]any, + observation string, + machinePayload map[string]any, +) WriteResultView { + normalizedStatus := normalizeStatus(status) + if normalizedStatus == "" { + normalizedStatus = StatusDone + } + + collapsed := map[string]any{ + "title": strings.TrimSpace(title), + "subtitle": strings.TrimSpace(subtitle), + "status": normalizedStatus, + "status_label": resolveStatusLabelCN(normalizedStatus), + "metrics": metricListToMaps(metrics), + } + expanded := map[string]any{ + "items": itemListToMaps(items), + "sections": cloneSectionList(sections), + "raw_text": observation, + } + if len(machinePayload) > 0 { + expanded["machine_payload"] = cloneAnyMap(machinePayload) + } + + return WriteResultView{ + ViewType: ViewTypeWriteResult, + Version: ViewVersionWriteResult, + Collapsed: collapsed, + Expanded: expanded, + } +} + +func buildMetric(label string, value string) MetricField { + return MetricField{ + Label: strings.TrimSpace(label), + Value: strings.TrimSpace(value), + } +} + +func buildKVField(label string, value string) KVField { + return KVField{ + Label: strings.TrimSpace(label), + Value: strings.TrimSpace(value), + } +} + +func buildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView { + return ItemView{ + Title: strings.TrimSpace(title), + Subtitle: strings.TrimSpace(subtitle), + Tags: normalizeStringSlice(tags), + DetailLines: normalizeStringSlice(detailLines), + Meta: cloneAnyMap(meta), + } +} + +func buildItemsSection(title string, items []ItemView) map[string]any { + return map[string]any{ + "type": "items", + "title": strings.TrimSpace(title), + "items": itemListToMaps(items), + } +} + +func buildKVSection(title string, fields []KVField) map[string]any { + rows := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + label := strings.TrimSpace(field.Label) + value := strings.TrimSpace(field.Value) + if label == "" || value == "" { + continue + } + rows = append(rows, map[string]any{ + "label": label, + "value": value, + }) + } + return map[string]any{ + "type": "kv", + "title": strings.TrimSpace(title), + "fields": rows, + } +} + +func buildCalloutSection(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 normalizeStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case StatusDone: + return StatusDone + case StatusBlocked: + return StatusBlocked + case StatusFailed: + return StatusFailed + default: + return "" + } +} + +func resolveStatusLabelCN(status string) string { + switch normalizeStatus(status) { + case StatusDone: + return "已完成" + case StatusBlocked: + return "已阻断" + default: + return "失败" + } +} + +func formatSourceCN(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case "chat": + return "对话" + case "memory": + return "记忆" + case "web": + return "网页" + case "": + return "未标注" + default: + return strings.TrimSpace(source) + } +} + +func formatModeCN(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "auto": + return "自动排布" + case "manual": + return "手动维护" + default: + return fallbackText(mode, "未标注") + } +} + +func formatStrategyCN(strategy string) string { + switch strings.ToLower(strings.TrimSpace(strategy)) { + case "steady": + return "稳态推进" + case "rapid": + return "快速推进" + default: + return fallbackText(strategy, "未标注") + } +} + +func formatSubjectTypeCN(subjectType string) string { + switch strings.ToLower(strings.TrimSpace(subjectType)) { + case "quantitative": + return "计算型" + case "memory": + return "记忆型" + case "reading": + return "阅读型" + case "mixed": + return "混合型" + default: + return fallbackText(subjectType, "未标注") + } +} + +func formatLevelCN(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case "low": + return "低" + case "medium": + return "中" + case "high": + return "高" + default: + return fallbackText(level, "未标注") + } +} + +func formatBoolCN(value bool) string { + if value { + return "是" + } + return "否" +} + +func formatDateRangeCN(start string, end string) string { + start = strings.TrimSpace(start) + end = strings.TrimSpace(end) + switch { + case start != "" && end != "": + return fmt.Sprintf("%s 至 %s", start, end) + case start != "": + return start + case end != "": + return end + default: + return "未标注" + } +} + +func formatIntListCN(values []int, emptyText string, formatFn func(int) string) string { + if len(values) == 0 { + return strings.TrimSpace(emptyText) + } + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, formatFn(value)) + } + return strings.Join(parts, "、") +} + +func formatWeekdayCN(day int) string { + switch day { + 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", day) + } +} + +func formatEmbeddedTimeCN(item TaskClassItemSummary) string { + if item.EmbeddedWeek <= 0 || item.EmbeddedDay <= 0 || item.EmbeddedSectionFrom <= 0 || item.EmbeddedSectionTo <= 0 { + return "未指定" + } + return fmt.Sprintf( + "第%d周 %s 第%d-%d节", + item.EmbeddedWeek, + formatWeekdayCN(item.EmbeddedDay), + item.EmbeddedSectionFrom, + item.EmbeddedSectionTo, + ) +} + +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 truncateText(text string, limit int) string { + runes := []rune(strings.TrimSpace(text)) + if len(runes) == 0 { + return "未填写内容" + } + if limit <= 0 || len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) + "..." +} + +func fallbackText(text string, fallback string) string { + if strings.TrimSpace(text) == "" { + return strings.TrimSpace(fallback) + } + return strings.TrimSpace(text) +} + +func metricListToMaps(metrics []MetricField) []map[string]any { + if len(metrics) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(metrics)) + for _, metric := range metrics { + label := strings.TrimSpace(metric.Label) + value := strings.TrimSpace(metric.Value) + if label == "" || value == "" { + continue + } + out = append(out, map[string]any{ + "label": label, + "value": value, + }) + } + if len(out) == 0 { + return make([]map[string]any, 0) + } + return out +} + +func itemListToMaps(items []ItemView) []map[string]any { + if len(items) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + row := map[string]any{ + "title": strings.TrimSpace(item.Title), + "subtitle": strings.TrimSpace(item.Subtitle), + "tags": normalizeStringSlice(item.Tags), + "detail_lines": normalizeStringSlice(item.DetailLines), + } + if len(item.Meta) > 0 { + row["meta"] = cloneAnyMap(item.Meta) + } + out = append(out, row) + } + return out +} + +func cloneSectionList(sections []map[string]any) []map[string]any { + if len(sections) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(sections)) + for _, section := range sections { + out = append(out, cloneAnyMap(section)) + } + return out +} + +func cloneAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = cloneAnyValue(value) + } + return out +} + +func cloneAnyValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneAnyMap(typed) + case []map[string]any: + out := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneAnyMap(item)) + } + return out + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneAnyValue(item)) + } + return out + case []string: + out := make([]string, len(typed)) + copy(out, typed) + return out + case []int: + out := make([]int, len(typed)) + copy(out, typed) + return out + default: + return typed + } +} diff --git a/backend/newAgent/tools/taskclass_result/types.go b/backend/newAgent/tools/taskclass_result/types.go new file mode 100644 index 0000000..c9d3828 --- /dev/null +++ b/backend/newAgent/tools/taskclass_result/types.go @@ -0,0 +1,114 @@ +package taskclass_result + +const ( + // ViewTypeWriteResult 固定为任务类写入结果卡片的前端识别类型。 + ViewTypeWriteResult = "taskclass.write_result" + + // ViewVersionWriteResult 固定为当前任务类写入结果结构版本。 + ViewVersionWriteResult = 1 + + // 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。 + StatusDone = "done" + StatusFailed = "failed" + StatusBlocked = "blocked" +) + +// WriteResultView 是子包暴露给父包 adapter 的纯展示结构。 +// +// 职责边界: +// 1. 只承载 view_type / version / collapsed / expanded 四段展示数据; +// 2. 不负责 ToolExecutionResult、SSE、timeline 等父包协议; +// 3. collapsed / expanded 继续保留 map 形态,便于父包直接桥接。 +type WriteResultView struct { + ViewType string `json:"view_type"` + Version int `json:"version"` + Collapsed map[string]any `json:"collapsed"` + Expanded map[string]any `json:"expanded"` +} + +// MetricField 是 collapsed.metrics 的轻量键值结构。 +type MetricField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// KVField 是 expanded.kv section 的轻量键值结构。 +type KVField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// ItemView 是 expanded.items / section.items 的通用结构。 +type ItemView struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Tags []string `json:"tags"` + DetailLines []string `json:"detail_lines"` + Meta map[string]any `json:"meta,omitempty"` +} + +// UpsertResult 承载写入 observation 里可稳定提取的结果字段。 +// +// 职责边界: +// 1. 只描述 upsert_task_class 的结果,不承载请求参数; +// 2. ValidationIssues 仅用于展示校验失败原因,不负责重新校验; +// 3. Error / ErrorCode 保持和 observation 一致,避免展示层发明新语义。 +type UpsertResult struct { + Tool string + Success bool + TaskClassID int + Created bool + Error string + ErrorCode string + ValidationOK bool + ValidationIssues []string +} + +// RequestSummary 描述写入请求中适合前端展示的稳定字段摘要。 +// +// 职责边界: +// 1. 只保留卡片展示稳定需要的信息,不回传完整原始 args; +// 2. RequestedID 表示调用方请求更新的任务类 ID,不等同于持久化后的真实 ID; +// 3. Items 已经是展示层可直接消费的扁平摘要,不再承担业务校验职责。 +type RequestSummary struct { + RequestedID int + Name string + Mode string + StartDate string + EndDate string + SubjectType string + DifficultyLevel string + CognitiveIntensity string + TotalSlots int + AllowFillerCourse bool + Strategy string + ExcludedSlots []int + ExcludedDaysOfWeek []int + Source string + Items []TaskClassItemSummary +} + +// TaskClassItemSummary 描述单个任务项的展示摘要。 +type TaskClassItemSummary struct { + ID int + Order int + Content string + EmbeddedWeek int + EmbeddedDay int + EmbeddedSectionFrom int + EmbeddedSectionTo int +} + +// BuildUpsertTaskClassViewInput 是 upsert_task_class 卡片 builder 的输入。 +// +// 职责边界: +// 1. Observation 必须原样传入,供 raw_text 保留原始结果; +// 2. MachinePayload 只作为调试/后续交互的隐藏字段,不参与标题摘要计算; +// 3. Status 由父包 adapter 传入,子包只负责标准化,不重新推断工具执行链路。 +type BuildUpsertTaskClassViewInput struct { + Status string + Observation string + Result UpsertResult + Request RequestSummary + MachinePayload map[string]any +} diff --git a/backend/newAgent/tools/taskclass_result/write.go b/backend/newAgent/tools/taskclass_result/write.go new file mode 100644 index 0000000..81c8f81 --- /dev/null +++ b/backend/newAgent/tools/taskclass_result/write.go @@ -0,0 +1,242 @@ +package taskclass_result + +import "fmt" + +// BuildUpsertTaskClassView 把 upsert_task_class 的稳定结果摘要转成任务类写入卡片。 +// +// 步骤化说明: +// 1. 先基于父包传入的 Status/Result 生成折叠态标题、摘要和稳定指标,保证成功/失败都能快速扫读; +// 2. 再把任务类字段、配置、任务项列表和失败原因拆成 kv/items/callout section,避免前端继续回退 raw_text; +// 3. raw_text 与 machine_payload 始终保留,便于模型链路、调试链路和后续交互共用同一份 observation 语义。 +func BuildUpsertTaskClassView(input BuildUpsertTaskClassViewInput) WriteResultView { + status := normalizeStatus(input.Status) + if status == "" { + if input.Result.Success { + status = StatusDone + } else { + status = StatusFailed + } + } + + items := buildTaskClassItemViews(input.Request.Items) + sections := buildUpsertSections(input.Result, input.Request, items, status) + + return buildWriteResultView( + status, + buildUpsertTitle(input.Result, status), + buildUpsertSubtitle(input.Result, input.Request, status), + buildUpsertMetrics(input.Result, input.Request), + items, + sections, + input.Observation, + input.MachinePayload, + ) +} + +func buildUpsertTitle(result UpsertResult, status string) string { + if normalizeStatus(status) != StatusDone { + return "任务类写入失败" + } + if result.Created { + return "任务类已创建" + } + return "任务类已更新" +} + +func buildUpsertSubtitle(result UpsertResult, request RequestSummary, status string) string { + name := fallbackText(request.Name, "未命名任务类") + itemCount := len(request.Items) + if normalizeStatus(status) == StatusDone { + action := "更新" + if result.Created { + action = "创建" + } + return fmt.Sprintf("已%s「%s」,共 %d 项任务", action, name, itemCount) + } + + if len(result.ValidationIssues) > 0 { + return fmt.Sprintf("「%s」校验未通过:%s", name, result.ValidationIssues[0]) + } + if result.Error != "" { + return fmt.Sprintf("「%s」写入失败:%s", name, result.Error) + } + return fmt.Sprintf("「%s」写入失败,请查看详情", name) +} + +func buildUpsertMetrics(result UpsertResult, request RequestSummary) []MetricField { + action := "更新" + if result.Created { + action = "创建" + } + if !result.Success && request.RequestedID == 0 { + action = "创建尝试" + } + if !result.Success && request.RequestedID > 0 { + action = "更新尝试" + } + + return []MetricField{ + buildMetric("任务类数量", "1 个"), + buildMetric("任务项数量", fmt.Sprintf("%d 项", len(request.Items))), + buildMetric("来源", formatSourceCN(request.Source)), + buildMetric("写入方式", action), + } +} + +func buildUpsertSections( + result UpsertResult, + request RequestSummary, + items []ItemView, + status string, +) []map[string]any { + sections := []map[string]any{ + buildResultCallout(result, request, status), + buildKVSection("任务类字段", buildTaskClassFields(result, request)), + buildKVSection("排程配置", buildTaskClassConfigFields(request)), + } + + if len(items) > 0 { + sections = append(sections, buildItemsSection("任务项列表", items)) + } else { + sections = append(sections, buildCalloutSection( + "任务项列表", + "当前没有可展示的任务项。", + "info", + []string{"如果这是一次失败写入,请优先检查 task_class.items 或顶层 items 入参是否完整。"}, + )) + } + + if len(result.ValidationIssues) > 0 { + sections = append(sections, buildCalloutSection( + "校验失败原因", + "请求参数未通过后端校验。", + "warning", + normalizeStringSlice(result.ValidationIssues), + )) + } + return sections +} + +func buildResultCallout(result UpsertResult, request RequestSummary, status string) map[string]any { + if normalizeStatus(status) == StatusDone { + action := "更新" + if result.Created { + action = "创建" + } + detailLines := []string{ + fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")), + fmt.Sprintf("任务类 ID:%d", resolveDisplayTaskClassID(result, request)), + fmt.Sprintf("任务项数量:%d 项", len(request.Items)), + } + return buildCalloutSection( + "写入结果", + fmt.Sprintf("已%s任务类,结果可直接用于后续排程。", action), + "success", + detailLines, + ) + } + + reason := result.Error + if len(result.ValidationIssues) > 0 { + reason = result.ValidationIssues[0] + } + if reason == "" { + reason = "写入流程未返回明确失败原因,请查看原始 observation。" + } + return buildCalloutSection( + "写入失败", + reason, + "danger", + []string{ + fmt.Sprintf("来源:%s", formatSourceCN(request.Source)), + fmt.Sprintf("任务类:%s", fallbackText(request.Name, "未命名任务类")), + fmt.Sprintf("任务项数量:%d 项", len(request.Items)), + }, + ) +} + +func buildTaskClassFields(result UpsertResult, request RequestSummary) []KVField { + return []KVField{ + buildKVField("任务类 ID", fmt.Sprintf("%d", resolveDisplayTaskClassID(result, request))), + buildKVField("名称", fallbackText(request.Name, "未命名任务类")), + buildKVField("模式", formatModeCN(request.Mode)), + buildKVField("日期范围", formatDateRangeCN(request.StartDate, request.EndDate)), + buildKVField("学科类型", formatSubjectTypeCN(request.SubjectType)), + buildKVField("难度等级", formatLevelCN(request.DifficultyLevel)), + buildKVField("认知强度", formatLevelCN(request.CognitiveIntensity)), + buildKVField("来源", formatSourceCN(request.Source)), + } +} + +func buildTaskClassConfigFields(request RequestSummary) []KVField { + return []KVField{ + buildKVField("总节数", fmt.Sprintf("%d", request.TotalSlots)), + buildKVField("允许补位课程", formatBoolCN(request.AllowFillerCourse)), + buildKVField("推进策略", formatStrategyCN(request.Strategy)), + buildKVField("排除半天块", formatIntListCN(request.ExcludedSlots, "无", func(value int) string { + return fmt.Sprintf("第%d块", value) + })), + buildKVField("排除星期", formatIntListCN(request.ExcludedDaysOfWeek, "无", formatWeekdayCN)), + } +} + +func buildTaskClassItemViews(items []TaskClassItemSummary) []ItemView { + if len(items) == 0 { + return make([]ItemView, 0) + } + out := make([]ItemView, 0, len(items)) + for _, item := range items { + detailLines := []string{ + "内容:" + fallbackText(item.Content, "未填写内容"), + "嵌入时间:" + formatEmbeddedTimeCN(item), + } + if item.ID > 0 { + detailLines = append(detailLines, fmt.Sprintf("任务项 ID:%d", item.ID)) + } + out = append(out, buildItem( + truncateText(item.Content, 28), + fmt.Sprintf("第 %d 项", maxInt(item.Order, 0)), + buildTaskClassItemTags(item), + detailLines, + map[string]any{ + "id": item.ID, + "order": item.Order, + "embedded_week": item.EmbeddedWeek, + "embedded_day": item.EmbeddedDay, + "section_from": item.EmbeddedSectionFrom, + "section_to": item.EmbeddedSectionTo, + }, + )) + } + return out +} + +func buildTaskClassItemTags(item TaskClassItemSummary) []string { + tags := []string{fmt.Sprintf("顺序 %d", maxInt(item.Order, 0))} + if item.EmbeddedWeek > 0 && item.EmbeddedDay > 0 { + tags = append(tags, formatEmbeddedTimeCN(item)) + } else { + tags = append(tags, "未指定嵌入时间") + } + return tags +} + +func resolveDisplayTaskClassID(result UpsertResult, request RequestSummary) int { + if result.TaskClassID > 0 { + return result.TaskClassID + } + return request.RequestedID +} + +func maxInt(values ...int) int { + if len(values) == 0 { + return 0 + } + best := values[0] + for _, value := range values[1:] { + if value > best { + best = value + } + } + return best +} diff --git a/backend/newAgent/tools/taskclass_result_handlers.go b/backend/newAgent/tools/taskclass_result_handlers.go new file mode 100644 index 0000000..7231ae1 --- /dev/null +++ b/backend/newAgent/tools/taskclass_result_handlers.go @@ -0,0 +1,458 @@ +package newagenttools + +import ( + "strings" + + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + taskclassresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/taskclass_result" +) + +type taskClassUpsertExecutionInput struct { + Result taskClassUpsertToolResult + Normalized *TaskClassUpsertInput +} + +// NewTaskClassUpsertToolHandler 返回 upsert_task_class 的结构化结果 handler。 +// +// 职责边界: +// 1. 只负责参数解析、校验、调用依赖与包装结构化结果; +// 2. 不改变既有写库语义、confirm 语义与 observation JSON 合约; +// 3. 老实现暂以 legacy 函数保留,便于本轮并行迁移后回溯与对照。 +func NewTaskClassUpsertToolHandler(deps TaskClassWriteDeps) ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + _ = state + + if deps.UpsertTaskClass == nil { + return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{ + Result: taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"任务类写库依赖未注入"}}, + Error: "任务类写库依赖未注入", + ErrorCode: "dependency_missing", + }, + }) + } + + userID, ok := readUpsertUserID(args["_user_id"]) + if !ok || userID <= 0 { + return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{ + Result: taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"无法识别用户身份"}}, + Error: "工具调用失败:无法识别用户身份", + ErrorCode: "missing_user_id", + }, + }) + } + + input, parseErr := parseTaskClassUpsertInput(args) + if parseErr != nil { + return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{ + Result: taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{parseErr.Error()}}, + Error: parseErr.Error(), + ErrorCode: "invalid_args", + }, + }) + } + + issues := validateTaskClassUpsertRequest(input.Request, input.ID) + if len(issues) > 0 { + return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{ + Result: taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: issues}, + Error: strings.Join(issues, ";"), + ErrorCode: "validation_failed", + }, + Normalized: &input, + }) + } + + result, err := deps.UpsertTaskClass(userID, input) + if err != nil { + return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{ + Result: taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"持久化写入失败"}}, + Error: err.Error(), + ErrorCode: "persist_failed", + }, + Normalized: &input, + }) + } + if result.TaskClassID <= 0 { + return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{ + Result: taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: false, + Validation: taskClassValidationResult{OK: false, Issues: []string{"未返回有效 task_class_id"}}, + Error: "写入后未返回有效 task_class_id", + ErrorCode: "invalid_persist_result", + }, + Normalized: &input, + }) + } + + return buildTaskClassUpsertExecutionResult(args, taskClassUpsertExecutionInput{ + Result: taskClassUpsertToolResult{ + Tool: "upsert_task_class", + Success: true, + TaskClassID: result.TaskClassID, + Created: result.Created, + Validation: taskClassValidationResult{OK: true, Issues: []string{}}, + Error: "", + ErrorCode: "", + }, + Normalized: &input, + }) + } +} + +// buildTaskClassUpsertExecutionResult 负责把 upsert_task_class 的原始 observation 包装成结构化卡片。 +// +// 步骤化说明: +// 1. 先沿用 LegacyResult 保留 observation、参数预览、错误码提取等既有链路能力; +// 2. 再把规范化请求摘要和写入结果投影到 taskclass_result 子包,避免展示层反向依赖父包; +// 3. 最后只替换 ResultView / Summary,不改写写库语义、confirm 语义和原始错误文本。 +func buildTaskClassUpsertExecutionResult( + args map[string]any, + input taskClassUpsertExecutionInput, +) ToolExecutionResult { + observation := marshalTaskClassUpsertResult(input.Result) + legacy := LegacyResult("upsert_task_class", args, observation) + + requestSummary := buildTaskClassRequestSummary(args, input.Normalized) + view := taskclassresult.BuildUpsertTaskClassView(taskclassresult.BuildUpsertTaskClassViewInput{ + Status: legacy.Status, + Observation: observation, + Result: taskclassresult.UpsertResult{ + Tool: strings.TrimSpace(input.Result.Tool), + Success: input.Result.Success, + TaskClassID: input.Result.TaskClassID, + Created: input.Result.Created, + Error: strings.TrimSpace(input.Result.Error), + ErrorCode: strings.TrimSpace(input.Result.ErrorCode), + ValidationOK: input.Result.Validation.OK, + ValidationIssues: append([]string(nil), input.Result.Validation.Issues...), + }, + Request: requestSummary, + MachinePayload: buildTaskClassMachinePayload(input.Result, requestSummary), + }) + return buildTaskClassWriteExecutionResult(legacy, args, view) +} + +// buildTaskClassWriteExecutionResult 负责把子包纯展示视图包回父包统一协议。 +func buildTaskClassWriteExecutionResult( + legacy ToolExecutionResult, + args map[string]any, + view taskclassresult.WriteResultView, +) ToolExecutionResult { + result := legacy + status := normalizeToolStatus(result.Status) + if status == "" { + status = ToolStatusDone + } + if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok { + if normalized := normalizeToolStatus(collapsedStatus); normalized != "" { + status = normalized + } + } + + collapsed := cloneAnyMap(view.Collapsed) + if collapsed == nil { + collapsed = make(map[string]any) + } + expanded := cloneAnyMap(view.Expanded) + if expanded == nil { + expanded = make(map[string]any) + } + + collapsed["status"] = status + if _, exists := collapsed["status_label"]; !exists { + collapsed["status_label"] = resolveToolStatusLabelCN(status) + } + if _, exists := expanded["raw_text"]; !exists { + expanded["raw_text"] = result.ObservationText + } + + viewType := strings.TrimSpace(view.ViewType) + if viewType == "" { + viewType = taskclassresult.ViewTypeWriteResult + } + version := view.Version + if version <= 0 { + version = taskclassresult.ViewVersionWriteResult + } + + result.Status = status + result.Success = status == ToolStatusDone + result.ResultView = &ToolDisplayView{ + ViewType: viewType, + Version: version, + Collapsed: collapsed, + Expanded: expanded, + } + if title, ok := readStringAnyMap(collapsed, "title"); ok { + result.Summary = title + } + if !result.Success { + errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status) + if strings.TrimSpace(result.ErrorCode) == "" { + result.ErrorCode = strings.TrimSpace(errorCode) + } + if strings.TrimSpace(result.ErrorMessage) == "" { + result.ErrorMessage = strings.TrimSpace(errorMessage) + } + } + return EnsureToolResultDefaults(result, args) +} + +func buildTaskClassRequestSummary( + args map[string]any, + normalized *TaskClassUpsertInput, +) taskclassresult.RequestSummary { + summary := buildTaskClassRequestSummaryFromArgs(args) + if normalized == nil { + return summary + } + + summary.RequestedID = normalized.ID + summary.Name = strings.TrimSpace(normalized.Request.Name) + summary.Mode = strings.TrimSpace(normalized.Request.Mode) + summary.StartDate = strings.TrimSpace(normalized.Request.StartDate) + summary.EndDate = strings.TrimSpace(normalized.Request.EndDate) + summary.SubjectType = strings.TrimSpace(normalized.Request.SubjectType) + summary.DifficultyLevel = strings.TrimSpace(normalized.Request.DifficultyLevel) + summary.CognitiveIntensity = strings.TrimSpace(normalized.Request.CognitiveIntensity) + summary.TotalSlots = normalized.Request.Config.TotalSlots + summary.AllowFillerCourse = normalized.Request.Config.AllowFillerCourse + summary.Strategy = strings.TrimSpace(normalized.Request.Config.Strategy) + summary.ExcludedSlots = cloneIntSlice(normalized.Request.Config.ExcludedSlots) + summary.ExcludedDaysOfWeek = cloneIntSlice(normalized.Request.Config.ExcludedDaysOfWeek) + summary.Source = strings.TrimSpace(normalized.Source) + summary.Items = buildTaskClassItemsSummary(normalized.Request.Items) + return summary +} + +func buildTaskClassRequestSummaryFromArgs(args map[string]any) taskclassresult.RequestSummary { + summary := taskclassresult.RequestSummary{ + RequestedID: readOptionalInt(args, "id"), + Source: readOptionalString(args, "source"), + Items: make([]taskclassresult.TaskClassItemSummary, 0), + ExcludedSlots: make([]int, 0), + ExcludedDaysOfWeek: make([]int, 0), + } + + taskClassMap, _ := readAnyMap(args["task_class"]) + if taskClassMap == nil { + return summary + } + + summary.Name = strings.TrimSpace(readAnyString(taskClassMap["name"])) + summary.Mode = strings.TrimSpace(readAnyString(taskClassMap["mode"])) + summary.StartDate = strings.TrimSpace(readAnyString(taskClassMap["start_date"])) + summary.EndDate = strings.TrimSpace(readAnyString(taskClassMap["end_date"])) + summary.SubjectType = strings.TrimSpace(readAnyString(taskClassMap["subject_type"])) + summary.DifficultyLevel = strings.TrimSpace(readAnyString(taskClassMap["difficulty_level"])) + summary.CognitiveIntensity = strings.TrimSpace(readAnyString(taskClassMap["cognitive_intensity"])) + + configMap, _ := readAnyMap(taskClassMap["config"]) + summary.TotalSlots = readAnyInt(configMap["total_slots"]) + summary.AllowFillerCourse = readAnyBool(configMap["allow_filler_course"]) + summary.Strategy = strings.TrimSpace(readAnyString(configMap["strategy"])) + summary.ExcludedSlots = readAnyIntSlice(configMap["excluded_slots"]) + summary.ExcludedDaysOfWeek = readAnyIntSlice(configMap["excluded_days_of_week"]) + + rawItems := taskClassMap["items"] + if topLevelItems, exists := args["items"]; exists { + rawItems = topLevelItems + } + summary.Items = buildTaskClassItemsSummaryFromRaw(rawItems) + return summary +} + +func buildTaskClassItemsSummary(items []model.UserAddTaskClassItemRequest) []taskclassresult.TaskClassItemSummary { + if len(items) == 0 { + return make([]taskclassresult.TaskClassItemSummary, 0) + } + out := make([]taskclassresult.TaskClassItemSummary, 0, len(items)) + for _, item := range items { + summary := taskclassresult.TaskClassItemSummary{ + ID: item.ID, + Order: item.Order, + Content: strings.TrimSpace(item.Content), + } + if item.EmbeddedTime != nil { + summary.EmbeddedWeek = item.EmbeddedTime.Week + summary.EmbeddedDay = item.EmbeddedTime.DayOfWeek + summary.EmbeddedSectionFrom = item.EmbeddedTime.SectionFrom + summary.EmbeddedSectionTo = item.EmbeddedTime.SectionTo + } + out = append(out, summary) + } + return out +} + +func buildTaskClassItemsSummaryFromRaw(raw any) []taskclassresult.TaskClassItemSummary { + rawList, ok := raw.([]any) + if !ok || len(rawList) == 0 { + return make([]taskclassresult.TaskClassItemSummary, 0) + } + out := make([]taskclassresult.TaskClassItemSummary, 0, len(rawList)) + for index, row := range rawList { + itemMap, ok := row.(map[string]any) + if !ok { + continue + } + summary := taskclassresult.TaskClassItemSummary{ + ID: readAnyInt(itemMap["id"]), + Order: maxPositiveInt(readAnyInt(itemMap["order"]), index+1), + Content: strings.TrimSpace(firstNonEmptyString( + readAnyString(itemMap["content"]), + readAnyString(itemMap["description"]), + readAnyString(itemMap["title"]), + readAnyString(itemMap["name"]), + )), + } + if embeddedMap, ok := readAnyMap(itemMap["embedded_time"]); ok { + summary.EmbeddedWeek = readAnyInt(embeddedMap["week"]) + summary.EmbeddedDay = readAnyInt(embeddedMap["day_of_week"]) + summary.EmbeddedSectionFrom = readAnyInt(embeddedMap["section_from"]) + summary.EmbeddedSectionTo = readAnyInt(embeddedMap["section_to"]) + } + out = append(out, summary) + } + return out +} + +func buildTaskClassMachinePayload( + result taskClassUpsertToolResult, + request taskclassresult.RequestSummary, +) map[string]any { + return map[string]any{ + "parsed_result": map[string]any{ + "tool": strings.TrimSpace(result.Tool), + "success": result.Success, + "task_class_id": result.TaskClassID, + "created": result.Created, + "validation": map[string]any{ + "ok": result.Validation.OK, + "issues": append([]string(nil), result.Validation.Issues...), + }, + "error": strings.TrimSpace(result.Error), + "error_code": strings.TrimSpace(result.ErrorCode), + }, + "input_summary": map[string]any{ + "requested_id": request.RequestedID, + "name": request.Name, + "mode": request.Mode, + "start_date": request.StartDate, + "end_date": request.EndDate, + "subject_type": request.SubjectType, + "difficulty_level": request.DifficultyLevel, + "cognitive_intensity": request.CognitiveIntensity, + "total_slots": request.TotalSlots, + "allow_filler_course": request.AllowFillerCourse, + "strategy": request.Strategy, + "excluded_slots": cloneIntSlice(request.ExcludedSlots), + "excluded_days_of_week": cloneIntSlice(request.ExcludedDaysOfWeek), + "source": request.Source, + "items": buildTaskClassItemMachinePayload(request.Items), + }, + } +} + +func buildTaskClassItemMachinePayload(items []taskclassresult.TaskClassItemSummary) []map[string]any { + if len(items) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + out = append(out, map[string]any{ + "id": item.ID, + "order": item.Order, + "content": item.Content, + "embedded_week": item.EmbeddedWeek, + "embedded_day": item.EmbeddedDay, + "section_from": item.EmbeddedSectionFrom, + "section_to": item.EmbeddedSectionTo, + }) + } + return out +} + +func readOptionalString(args map[string]any, key string) string { + if len(args) == 0 { + return "" + } + return strings.TrimSpace(readAnyString(args[key])) +} + +func readOptionalInt(args map[string]any, key string) int { + if len(args) == 0 { + return 0 + } + return readAnyInt(args[key]) +} + +func readAnyInt(raw any) int { + value, ok := readUpsertInt(raw) + if !ok { + return 0 + } + return value +} + +func readAnyBool(raw any) bool { + value, ok := raw.(bool) + return ok && value +} + +func readAnyIntSlice(raw any) []int { + switch typed := raw.(type) { + case []int: + return cloneIntSlice(typed) + case []any: + out := make([]int, 0, len(typed)) + for _, item := range typed { + value, ok := readUpsertInt(item) + if !ok { + continue + } + out = append(out, value) + } + return out + default: + return make([]int, 0) + } +} + +func cloneIntSlice(values []int) []int { + if len(values) == 0 { + return make([]int, 0) + } + out := make([]int, len(values)) + copy(out, values) + return out +} + +func maxPositiveInt(left int, right int) int { + if left <= 0 { + return right + } + if right <= 0 { + return left + } + if left > right { + return left + } + return right +} diff --git a/backend/newAgent/tools/tool_context_result/context_result.go b/backend/newAgent/tools/tool_context_result/context_result.go new file mode 100644 index 0000000..66b96eb --- /dev/null +++ b/backend/newAgent/tools/tool_context_result/context_result.go @@ -0,0 +1,653 @@ +package toolcontextresult + +import ( + "fmt" + "strings" +) + +const ( + ViewTypeContextResult = "tool.context_result" + ViewVersionContextResult = 1 + StatusDone = "done" + StatusFailed = "failed" +) + +// ContextResultView 仅承载 context 工具卡片的纯展示数据。 +// +// 职责边界: +// 1. 负责输出 view_type / version / collapsed / expanded 四段展示结构; +// 2. 不依赖父包 ToolExecutionResult,避免形成反向 import; +// 3. 不改写 ObservationText,原始文本由父包原样挂到 expanded.raw_text。 +type ContextResultView struct { + ViewType string `json:"view_type"` + Version int `json:"version"` + Collapsed map[string]any `json:"collapsed"` + Expanded map[string]any `json:"expanded"` +} + +type MetricField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +type KVField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +type ItemView struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Tags []string `json:"tags"` + DetailLines []string `json:"detail_lines"` + Meta map[string]any `json:"meta,omitempty"` +} + +// ContextToolsAddPayload 对齐 context_tools_add observation 的机器字段。 +type ContextToolsAddPayload struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Action string `json:"action"` + Domain string `json:"domain,omitempty"` + Packs []string `json:"packs,omitempty"` + Mode string `json:"mode,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +// ContextToolsRemovePayload 对齐 context_tools_remove observation 的机器字段。 +type ContextToolsRemovePayload struct { + Tool string `json:"tool"` + Success bool `json:"success"` + Action string `json:"action"` + Domain string `json:"domain,omitempty"` + Packs []string `json:"packs,omitempty"` + All bool `json:"all,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +// BuildAddView 生成 context_tools_add 的结构化卡片。 +func BuildAddView(payload ContextToolsAddPayload, observation string) ContextResultView { + status := statusFromSuccess(payload.Success) + summary := buildAddSummary(payload) + detailLines := buildAddDetailLines(payload) + item := BuildItem( + fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域"), + summary, + buildAddTags(payload), + detailLines, + map[string]any{ + "action": payload.Action, + "domain": strings.TrimSpace(payload.Domain), + "mode": strings.TrimSpace(payload.Mode), + }, + ) + + sections := []map[string]any{ + buildContextCalloutSection( + fallbackText(buildAddCalloutTitle(payload), "工具域变更"), + summary, + toneFromSuccess(payload.Success), + detailLines, + ), + BuildKVSection("当前工具区参数", []KVField{ + BuildKVField("工具域", fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")), + BuildKVField("工具包", buildAddPackField(payload)), + BuildKVField("注入模式", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")), + BuildKVField("清空全部", "否"), + BuildKVField("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))), + }), + BuildItemsSection("变更摘要", "", []ItemView{item}), + } + if !payload.Success { + appendErrorSection(§ions, payload.Error, payload.ErrorCode) + } + + return ContextResultView{ + ViewType: ViewTypeContextResult, + Version: ViewVersionContextResult, + Collapsed: map[string]any{ + "title": buildAddCollapsedTitle(payload), + "subtitle": summary, + "status": status, + "status_label": resolveStatusLabelCN(status), + "metrics": buildMetrics( + BuildMetric("域", fallbackText(shortDomainLabel(payload.Domain), "未指定")), + BuildMetric("包", fmt.Sprintf("%d 个", len(payload.Packs))), + BuildMetric("模式", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")), + ), + }, + Expanded: map[string]any{ + "items": []map[string]any{item.Map()}, + "sections": sections, + "raw_text": observation, + "machine_payload": buildAddMachinePayload(payload), + }, + } +} + +// BuildRemoveView 生成 context_tools_remove 的结构化卡片。 +func BuildRemoveView(payload ContextToolsRemovePayload, observation string) ContextResultView { + status := statusFromSuccess(payload.Success) + summary := buildRemoveSummary(payload) + detailLines := buildRemoveDetailLines(payload) + item := BuildItem( + buildRemoveItemTitle(payload), + summary, + buildRemoveTags(payload), + detailLines, + map[string]any{ + "action": payload.Action, + "domain": strings.TrimSpace(payload.Domain), + "all": payload.All, + }, + ) + + sections := []map[string]any{ + buildContextCalloutSection( + fallbackText(buildRemoveCalloutTitle(payload), "工具域变更"), + summary, + toneFromSuccess(payload.Success), + detailLines, + ), + BuildKVSection("当前工具区参数", []KVField{ + BuildKVField("工具域", buildRemoveDomainField(payload)), + BuildKVField("工具包", buildRemovePackField(payload)), + BuildKVField("注入模式", "不适用"), + BuildKVField("清空全部", formatBoolCN(payload.All)), + BuildKVField("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))), + }), + BuildItemsSection("变更摘要", "", []ItemView{item}), + } + if !payload.Success { + appendErrorSection(§ions, payload.Error, payload.ErrorCode) + } + + return ContextResultView{ + ViewType: ViewTypeContextResult, + Version: ViewVersionContextResult, + Collapsed: map[string]any{ + "title": buildRemoveCollapsedTitle(payload), + "subtitle": summary, + "status": status, + "status_label": resolveStatusLabelCN(status), + "metrics": buildMetrics( + BuildMetric("范围", buildRemoveMetricScope(payload)), + BuildMetric("包", fmt.Sprintf("%d 个", len(payload.Packs))), + BuildMetric("动作", fallbackText(ResolveActionLabelCN(payload.Action), strings.TrimSpace(payload.Action))), + ), + }, + Expanded: map[string]any{ + "items": []map[string]any{item.Map()}, + "sections": sections, + "raw_text": observation, + "machine_payload": buildRemoveMachinePayload(payload), + }, + } +} + +func ResolveDomainLabelCN(domain string) string { + switch strings.ToLower(strings.TrimSpace(domain)) { + case "schedule": + return "排程工具域" + case "taskclass": + return "任务类工具域" + default: + return "" + } +} + +func ResolvePackLabelCN(pack string) string { + switch strings.ToLower(strings.TrimSpace(pack)) { + case "core": + return "固定 core" + case "mutation": + return "排程改写" + case "analyze": + return "健康分析" + case "detail_read": + return "明细读取" + case "deep_analyze": + return "深度分析" + case "queue": + return "队列微调" + case "web": + return "网页能力" + default: + return strings.TrimSpace(pack) + } +} + +func FormatPacksCN(packs []string) string { + if len(packs) == 0 { + return "无" + } + parts := make([]string, 0, len(packs)) + for _, pack := range packs { + label := strings.TrimSpace(ResolvePackLabelCN(pack)) + if label == "" { + continue + } + parts = append(parts, label) + } + if len(parts) == 0 { + return "无" + } + return strings.Join(parts, "、") +} + +func ResolveModeLabelCN(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "replace": + return "替换" + case "merge": + return "合并" + default: + return strings.TrimSpace(mode) + } +} + +func ResolveActionLabelCN(action string) string { + switch strings.ToLower(strings.TrimSpace(action)) { + case "activate": + return "激活" + case "deactivate": + return "移除域" + case "deactivate_packs": + return "移除包" + case "clear_all": + return "清空全部" + case "reject": + return "拒绝" + default: + return strings.TrimSpace(action) + } +} + +func BuildMetric(label string, value string) MetricField { + return MetricField{ + Label: strings.TrimSpace(label), + Value: strings.TrimSpace(value), + } +} + +func BuildKVField(label string, value string) KVField { + return KVField{ + Label: strings.TrimSpace(label), + Value: strings.TrimSpace(value), + } +} + +func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView { + return ItemView{ + Title: strings.TrimSpace(title), + Subtitle: strings.TrimSpace(subtitle), + Tags: normalizeStringSlice(tags), + DetailLines: normalizeStringSlice(detailLines), + Meta: cloneAnyMap(meta), + } +} + +func BuildItemsSection(title string, summary string, items []ItemView) map[string]any { + normalized := make([]map[string]any, 0, len(items)) + for _, item := range items { + normalized = append(normalized, item.Map()) + } + return map[string]any{ + "type": "items", + "title": strings.TrimSpace(title), + "summary": strings.TrimSpace(summary), + "items": normalized, + } +} + +func BuildKVSection(title string, fields []KVField) map[string]any { + normalized := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + label := strings.TrimSpace(field.Label) + value := strings.TrimSpace(field.Value) + if label == "" || value == "" { + continue + } + normalized = append(normalized, map[string]any{ + "label": label, + "value": value, + }) + } + return map[string]any{ + "type": "kv", + "title": strings.TrimSpace(title), + "fields": normalized, + } +} + +func buildContextCalloutSection(title string, summary string, tone string, detailLines []string) map[string]any { + return map[string]any{ + "type": "callout", + "title": strings.TrimSpace(title), + "summary": strings.TrimSpace(summary), + "tone": strings.TrimSpace(tone), + "detail_lines": normalizeStringSlice(detailLines), + } +} + +func appendErrorSection(target *[]map[string]any, reason string, errorCode string) { + lines := make([]string, 0, 2) + if strings.TrimSpace(reason) != "" { + lines = append(lines, strings.TrimSpace(reason)) + } + if strings.TrimSpace(errorCode) != "" { + lines = append(lines, fmt.Sprintf("错误码:%s", strings.TrimSpace(errorCode))) + } + *target = append(*target, buildContextCalloutSection("失败原因", fallbackText(reason, "工具调用失败"), "danger", lines)) +} + +func buildAddCollapsedTitle(payload ContextToolsAddPayload) string { + if !payload.Success { + return "激活工具域失败" + } + label := ResolveDomainLabelCN(payload.Domain) + if label == "" { + return "已激活工具域" + } + return fmt.Sprintf("已激活%s", label) +} + +func buildRemoveCollapsedTitle(payload ContextToolsRemovePayload) string { + if !payload.Success { + return "移除工具域失败" + } + if payload.All { + return "已清空业务工具域" + } + if len(payload.Packs) > 0 { + return "已移除工具包" + } + label := ResolveDomainLabelCN(payload.Domain) + if label == "" { + return "已移除工具域" + } + return fmt.Sprintf("已移除%s", label) +} + +func buildAddCalloutTitle(payload ContextToolsAddPayload) string { + if payload.Success { + return "动态工具区已更新" + } + return "激活失败" +} + +func buildRemoveCalloutTitle(payload ContextToolsRemovePayload) string { + if payload.Success { + return "动态工具区已更新" + } + return "移除失败" +} + +func buildAddSummary(payload ContextToolsAddPayload) string { + if !payload.Success { + return fallbackText(payload.Error, "激活工具域失败") + } + domainLabel := fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域") + packsText := formatPackFieldText(payload.Packs) + modeLabel := fallbackText(ResolveModeLabelCN(payload.Mode), "替换") + if len(payload.Packs) == 0 { + return fmt.Sprintf("%s已激活,模式=%s,仅保留固定 core。", domainLabel, modeLabel) + } + return fmt.Sprintf("%s已激活,模式=%s,启用 %s。", domainLabel, modeLabel, packsText) +} + +func buildRemoveSummary(payload ContextToolsRemovePayload) string { + if !payload.Success { + return fallbackText(payload.Error, "移除工具域失败") + } + if payload.All { + return "已清空全部业务工具域,仅保留 context 管理工具。" + } + domainLabel := fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域") + if len(payload.Packs) > 0 { + return fmt.Sprintf("已从%s移除 %s。", domainLabel, FormatPacksCN(payload.Packs)) + } + return fmt.Sprintf("已移除%s。", domainLabel) +} + +func buildAddDetailLines(payload ContextToolsAddPayload) []string { + lines := []string{ + fmt.Sprintf("工具域:%s", fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定")), + fmt.Sprintf("工具包:%s", buildAddPackField(payload)), + fmt.Sprintf("注入模式:%s", fallbackText(ResolveModeLabelCN(payload.Mode), "未指定")), + } + if strings.TrimSpace(payload.Message) != "" { + lines = append(lines, strings.TrimSpace(payload.Message)) + } + if !payload.Success && strings.TrimSpace(payload.Error) != "" { + lines = append(lines, fmt.Sprintf("失败原因:%s", strings.TrimSpace(payload.Error))) + } + return lines +} + +func buildRemoveDetailLines(payload ContextToolsRemovePayload) []string { + lines := []string{ + fmt.Sprintf("工具域:%s", buildRemoveDomainField(payload)), + fmt.Sprintf("工具包:%s", buildRemovePackField(payload)), + fmt.Sprintf("清空全部:%s", formatBoolCN(payload.All)), + } + if strings.TrimSpace(payload.Message) != "" { + lines = append(lines, strings.TrimSpace(payload.Message)) + } + if !payload.Success && strings.TrimSpace(payload.Error) != "" { + lines = append(lines, fmt.Sprintf("失败原因:%s", strings.TrimSpace(payload.Error))) + } + return lines +} + +func buildAddTags(payload ContextToolsAddPayload) []string { + tags := []string{ + fallbackText(ResolveActionLabelCN(payload.Action), "激活"), + } + if modeLabel := strings.TrimSpace(ResolveModeLabelCN(payload.Mode)); modeLabel != "" { + tags = append(tags, modeLabel) + } + if len(payload.Packs) > 0 { + tags = append(tags, fmt.Sprintf("%d 个包", len(payload.Packs))) + } + return normalizeStringSlice(tags) +} + +func buildRemoveTags(payload ContextToolsRemovePayload) []string { + tags := []string{ + fallbackText(ResolveActionLabelCN(payload.Action), "移除"), + } + if payload.All { + tags = append(tags, "全部") + } + if len(payload.Packs) > 0 { + tags = append(tags, fmt.Sprintf("%d 个包", len(payload.Packs))) + } + return normalizeStringSlice(tags) +} + +func buildRemoveItemTitle(payload ContextToolsRemovePayload) string { + if payload.All { + return "全部业务工具域" + } + return fallbackText(ResolveDomainLabelCN(payload.Domain), "工具域") +} + +func buildRemoveDomainField(payload ContextToolsRemovePayload) string { + if payload.All { + return "全部业务工具域" + } + return fallbackText(ResolveDomainLabelCN(payload.Domain), "未指定") +} + +func buildRemoveMetricScope(payload ContextToolsRemovePayload) string { + if payload.All { + return "全部" + } + return fallbackText(shortDomainLabel(payload.Domain), "单域") +} + +func buildAddMachinePayload(payload ContextToolsAddPayload) map[string]any { + return map[string]any{ + "tool": payload.Tool, + "success": payload.Success, + "action": strings.TrimSpace(payload.Action), + "domain": strings.TrimSpace(payload.Domain), + "packs": append([]string(nil), payload.Packs...), + "mode": strings.TrimSpace(payload.Mode), + "message": strings.TrimSpace(payload.Message), + "error": strings.TrimSpace(payload.Error), + "error_code": strings.TrimSpace(payload.ErrorCode), + } +} + +func buildRemoveMachinePayload(payload ContextToolsRemovePayload) map[string]any { + return map[string]any{ + "tool": payload.Tool, + "success": payload.Success, + "action": strings.TrimSpace(payload.Action), + "domain": strings.TrimSpace(payload.Domain), + "packs": append([]string(nil), payload.Packs...), + "all": payload.All, + "message": strings.TrimSpace(payload.Message), + "error": strings.TrimSpace(payload.Error), + "error_code": strings.TrimSpace(payload.ErrorCode), + } +} + +func buildMetrics(metrics ...MetricField) []map[string]any { + normalized := make([]map[string]any, 0, len(metrics)) + for _, metric := range metrics { + label := strings.TrimSpace(metric.Label) + value := strings.TrimSpace(metric.Value) + if label == "" || value == "" { + continue + } + normalized = append(normalized, map[string]any{ + "label": label, + "value": value, + }) + } + return normalized +} + +func toneFromSuccess(success bool) string { + if success { + return "info" + } + return "danger" +} + +func statusFromSuccess(success bool) string { + if success { + return StatusDone + } + return StatusFailed +} + +func resolveStatusLabelCN(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case StatusDone: + return "已完成" + default: + return "失败" + } +} + +func shortDomainLabel(domain string) string { + switch strings.ToLower(strings.TrimSpace(domain)) { + case "schedule": + return "排程" + case "taskclass": + return "任务类" + default: + return strings.TrimSpace(domain) + } +} + +func formatPackFieldText(packs []string) string { + if len(packs) == 0 { + return "无" + } + return FormatPacksCN(packs) +} + +func buildAddPackField(payload ContextToolsAddPayload) string { + if len(payload.Packs) == 0 { + return "仅固定 core" + } + return FormatPacksCN(payload.Packs) +} + +func buildRemovePackField(payload ContextToolsRemovePayload) string { + if len(payload.Packs) == 0 { + if payload.All { + return "全部清空" + } + if strings.EqualFold(strings.TrimSpace(payload.Action), "deactivate") { + return "未指定(按整域处理)" + } + return "无" + } + return FormatPacksCN(payload.Packs) +} + +func formatBoolCN(value bool) string { + if value { + return "是" + } + return "否" +} + +func fallbackText(text string, fallback string) string { + if strings.TrimSpace(text) == "" { + return strings.TrimSpace(fallback) + } + return strings.TrimSpace(text) +} + +func normalizeStringSlice(values []string) []string { + if len(values) == 0 { + return nil + } + 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 nil + } + return out +} + +func cloneAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func (view ItemView) Map() map[string]any { + item := map[string]any{ + "title": strings.TrimSpace(view.Title), + "subtitle": strings.TrimSpace(view.Subtitle), + "tags": normalizeStringSlice(view.Tags), + "detail_lines": normalizeStringSlice(view.DetailLines), + } + if len(view.Meta) > 0 { + item["meta"] = cloneAnyMap(view.Meta) + } + return item +} diff --git a/backend/newAgent/tools/web_result/common.go b/backend/newAgent/tools/web_result/common.go new file mode 100644 index 0000000..8fe9e4a --- /dev/null +++ b/backend/newAgent/tools/web_result/common.go @@ -0,0 +1,432 @@ +package web_result + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// 设计说明: +// 1. 本轮只处理 web 工具卡片,按 AGENTS.md 的迁移约束避免同一轮跨多个能力域抽公共 toolview 层。 +// 2. 因此这里先在 web_result 包内保留最小公共 helper,保证 web_search / web_fetch 先完成切流。 +// 3. 若后续 taskclass / context 也出现同类卡片 helper,再由主代理统一评估是否下沉成公共层。 + +// BuildResultView 统一封装 web 结果卡片结构。 +// +// 职责边界: +// 1. 负责把已经计算好的折叠态、展开态内容组装成标准视图。 +// 2. 负责在子包内补齐 status / status_label,避免依赖父包状态常量。 +// 3. 不负责 ToolExecutionResult 外层协议,也不改写 observation 原文。 +func BuildResultView(input BuildResultViewInput) ResultView { + status := normalizeStatus(input.Status) + if status == "" { + status = StatusDone + } + + collapsed := CollapsedView{ + Title: input.Title, + Subtitle: input.Subtitle, + Status: status, + StatusLabel: resolveStatusLabelCN(status), + Metrics: appendMetricCopy(input.Metrics), + } + expanded := ExpandedView{ + Items: appendItemCopy(input.Items), + Sections: cloneSectionList(input.Sections), + RawText: input.Observation, + MachinePayload: cloneAnyMap(input.MachinePayload), + } + + return ResultView{ + ViewType: normalizeViewType(input.ViewType), + Version: ViewVersionResult, + Collapsed: collapsed.Map(), + Expanded: expanded.Map(), + } +} + +func BuildMetric(label string, value string) MetricField { + return MetricField{ + Label: strings.TrimSpace(label), + Value: strings.TrimSpace(value), + } +} + +func BuildKVField(label string, value string) KVField { + return KVField{ + Label: strings.TrimSpace(label), + Value: strings.TrimSpace(value), + } +} + +func BuildItem(title string, subtitle string, tags []string, detailLines []string, meta map[string]any) ItemView { + return ItemView{ + Title: strings.TrimSpace(title), + Subtitle: strings.TrimSpace(subtitle), + Tags: normalizeStringSlice(tags), + DetailLines: normalizeStringSlice(detailLines), + Meta: cloneAnyMap(meta), + } +} + +func BuildKVSection(title string, fields []KVField) map[string]any { + rows := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + label := strings.TrimSpace(field.Label) + value := strings.TrimSpace(field.Value) + if label == "" || value == "" { + continue + } + rows = append(rows, map[string]any{ + "label": label, + "value": value, + }) + } + return map[string]any{ + "type": "kv", + "title": strings.TrimSpace(title), + "fields": rows, + } +} + +func BuildItemsSection(title string, items []ItemView) map[string]any { + rows := make([]map[string]any, 0, len(items)) + for _, item := range items { + rows = append(rows, item.Map()) + } + return map[string]any{ + "type": "items", + "title": strings.TrimSpace(title), + "items": rows, + } +} + +func BuildCalloutSection(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 BuildArgsSection(title string, fields []KVField) map[string]any { + if len(fields) == 0 { + return nil + } + valid := make([]KVField, 0, len(fields)) + for _, field := range fields { + label := strings.TrimSpace(field.Label) + value := strings.TrimSpace(field.Value) + if label == "" || value == "" { + continue + } + valid = append(valid, BuildKVField(label, value)) + } + if len(valid) == 0 { + return nil + } + return BuildKVSection(title, valid) +} + +func appendSectionIfPresent(target *[]map[string]any, section map[string]any) { + if section == nil { + return + } + *target = append(*target, section) +} + +func appendMetricCopy(metrics []MetricField) []MetricField { + if len(metrics) == 0 { + return make([]MetricField, 0) + } + out := make([]MetricField, 0, len(metrics)) + for _, metric := range metrics { + label := strings.TrimSpace(metric.Label) + value := strings.TrimSpace(metric.Value) + if label == "" || value == "" { + continue + } + out = append(out, MetricField{Label: label, Value: value}) + } + if len(out) == 0 { + return make([]MetricField, 0) + } + return out +} + +func appendItemCopy(items []ItemView) []ItemView { + if len(items) == 0 { + return make([]ItemView, 0) + } + out := make([]ItemView, 0, len(items)) + for _, item := range items { + out = append(out, BuildItem(item.Title, item.Subtitle, item.Tags, item.DetailLines, item.Meta)) + } + return out +} + +func normalizeViewType(viewType string) string { + switch strings.TrimSpace(viewType) { + case ViewTypeFetchResult: + return ViewTypeFetchResult + case ViewTypeSearchResult: + return ViewTypeSearchResult + default: + return ViewTypeSearchResult + } +} + +func normalizeStatus(status string) string { + switch strings.ToLower(strings.TrimSpace(status)) { + case StatusDone: + return StatusDone + case StatusBlocked: + return StatusBlocked + case StatusFailed: + return StatusFailed + default: + return "" + } +} + +func resolveStatusLabelCN(status string) string { + switch normalizeStatus(status) { + case StatusDone: + return "已完成" + case StatusBlocked: + return "已阻断" + default: + return "失败" + } +} + +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 parseObservationJSON(observation string) (map[string]any, bool) { + trimmed := strings.TrimSpace(observation) + if trimmed == "" || !strings.HasPrefix(trimmed, "{") { + return nil, false + } + var payload map[string]any + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return nil, false + } + return payload, true +} + +func cloneSectionList(sections []map[string]any) []map[string]any { + if len(sections) == 0 { + return make([]map[string]any, 0) + } + out := make([]map[string]any, 0, len(sections)) + for _, section := range sections { + out = append(out, cloneAnyMap(section)) + } + return out +} + +func cloneAnyMap(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = cloneAnyValue(value) + } + return out +} + +func cloneAnyValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneAnyMap(typed) + case []map[string]any: + out := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneAnyMap(item)) + } + return out + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneAnyValue(item)) + } + return out + case []string: + out := make([]string, len(typed)) + copy(out, typed) + return out + default: + return typed + } +} + +func firstString(input map[string]any, keys ...string) string { + for _, key := range keys { + if value := readString(input, key); value != "" { + return value + } + } + return "" +} + +func readString(input map[string]any, key string) string { + if len(input) == 0 { + return "" + } + value, exists := input[key] + if !exists || value == nil { + return "" + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + default: + text := strings.TrimSpace(fmt.Sprintf("%v", typed)) + if text == "" || text == "" { + return "" + } + return text + } +} + +func readBool(input map[string]any, key string) (bool, bool) { + if len(input) == 0 { + return false, false + } + value, exists := input[key] + if !exists { + return false, false + } + typed, ok := value.(bool) + return typed, ok +} + +func readInt(input map[string]any, key string) int { + if len(input) == 0 { + return 0 + } + value, exists := input[key] + if !exists || value == nil { + return 0 + } + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float64: + return int(typed) + default: + return 0 + } +} + +func previewText(text string, limit int) string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "" + } + runes := []rune(trimmed) + if limit <= 0 || len(runes) <= limit { + return string(runes) + } + return string(runes[:limit]) + "..." +} + +func previewLines(text string, maxLines int, maxChars int) []string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return make([]string, 0) + } + + lines := strings.Split(trimmed, "\n") + out := make([]string, 0, maxLines) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + out = append(out, previewText(line, maxChars)) + if maxLines > 0 && len(out) >= maxLines { + break + } + } + if len(out) == 0 { + out = append(out, previewText(trimmed, maxChars)) + } + return out +} + +func formatStringSliceCN(items []string, limit int) string { + normalized := normalizeStringSlice(items) + if len(normalized) == 0 { + return "" + } + if limit <= 0 || len(normalized) <= limit { + return strings.Join(normalized, "、") + } + return fmt.Sprintf("%s 等 %d 个", strings.Join(normalized[:limit], "、"), len(normalized)) +} + +func formatBoolCN(value bool) string { + if value { + return "是" + } + return "否" +} + +func classifyUnavailableStatus(message string) string { + trimmed := strings.TrimSpace(message) + lower := strings.ToLower(trimmed) + switch { + case strings.Contains(trimmed, "暂未启用"), + strings.Contains(trimmed, "未启用"), + strings.Contains(trimmed, "暂未初始化"), + strings.Contains(trimmed, "未初始化"), + strings.Contains(trimmed, "未配置"), + strings.Contains(lower, "not enabled"), + strings.Contains(lower, "not configured"), + strings.Contains(lower, "unavailable"): + return StatusBlocked + default: + return StatusFailed + } +} + +func buildRawPreviewSection(rawText string) map[string]any { + preview := previewText(rawText, 160) + if preview == "" { + return nil + } + return BuildCalloutSection("原始结果预览", preview, "info", previewLines(rawText, 3, 120)) +} + +func hostnameFromURL(rawURL string) string { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "" + } + return strings.TrimSpace(parsed.Hostname()) +} diff --git a/backend/newAgent/tools/web_result/fetch.go b/backend/newAgent/tools/web_result/fetch.go new file mode 100644 index 0000000..4357cc3 --- /dev/null +++ b/backend/newAgent/tools/web_result/fetch.go @@ -0,0 +1,232 @@ +package web_result + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +type fetchObservation struct { + Tool string `json:"tool"` + URL string `json:"url"` + Title string `json:"title"` + Content string `json:"content"` + Truncated bool `json:"truncated"` + Error string `json:"error"` + Err string `json:"err"` + Reason string `json:"reason"` +} + +// BuildFetchView 负责把 web_fetch observation 构造成前端可直接消费的结果卡片。 +// +// 职责边界: +// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。 +// 2. 负责保留 raw_text 与 machine_payload,便于前端调试与后续交互。 +// 3. 不负责真正抓取网页,也不改写传入 observation 原文。 +func BuildFetchView(input FetchViewInput) ResultView { + payloadMap, ok := parseObservationJSON(input.Observation) + if !ok { + return buildFetchTextFallbackView(input) + } + + payload := fetchObservation{} + if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil { + return buildFetchTextFallbackView(input) + } + + rawURL := strings.TrimSpace(payload.URL) + if rawURL == "" { + rawURL = strings.TrimSpace(input.URL) + } + + errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason) + if errorMessage == "" { + errorMessage = firstString(payloadMap, "message") + } + if errorMessage != "" { + return buildFetchFailureView(input, rawURL, errorMessage, payloadMap) + } + + title := strings.TrimSpace(payload.Title) + content := strings.TrimSpace(payload.Content) + host := hostnameFromURL(rawURL) + contentChars := utf8.RuneCountInString(content) + if title == "" { + title = "网页正文" + if host != "" { + title = host + } + } + + itemTags := make([]string, 0, 2) + if host != "" { + itemTags = append(itemTags, host) + } + if payload.Truncated { + itemTags = append(itemTags, "已截断") + } + + items := []ItemView{ + BuildItem( + title, + rawURL, + itemTags, + buildFetchPreviewLines(content), + map[string]any{ + "url": rawURL, + "title": strings.TrimSpace(payload.Title), + "content_len": contentChars, + "truncated": payload.Truncated, + }, + ), + } + + sections := []map[string]any{ + BuildKVSection("页面信息", []KVField{ + BuildKVField("链接", rawURL), + BuildKVField("标题", fallbackText(payload.Title, "未提取到标题")), + BuildKVField("正文长度", fmt.Sprintf("%d 字", contentChars)), + BuildKVField("是否截断", formatBoolCN(payload.Truncated)), + }), + BuildCalloutSection( + "正文预览", + previewText(content, 120), + "info", + buildFetchPreviewLines(content), + ), + } + appendSectionIfPresent(§ions, BuildArgsSection("抓取参数", buildFetchArgFields(input))) + + return BuildResultView(BuildResultViewInput{ + ViewType: ViewTypeFetchResult, + Status: StatusDone, + Title: buildFetchTitle(title), + Subtitle: buildFetchSubtitle(rawURL, host), + Metrics: buildFetchMetrics(contentChars, payload.Truncated, host), + Items: items, + Sections: sections, + Observation: input.Observation, + MachinePayload: payloadMap, + }) +} + +func buildFetchTextFallbackView(input FetchViewInput) ResultView { + subtitle := "抓取结果不是合法 JSON,已回退为文本预览。" + if strings.TrimSpace(input.Observation) == "" { + subtitle = "抓取工具没有返回结构化结果,已回退为文本预览。" + } + + sections := []map[string]any{ + BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}), + } + appendSectionIfPresent(§ions, BuildArgsSection("抓取参数", buildFetchArgFields(input))) + appendSectionIfPresent(§ions, buildRawPreviewSection(input.Observation)) + + return BuildResultView(BuildResultViewInput{ + ViewType: ViewTypeFetchResult, + Status: StatusFailed, + Title: "网页抓取结果不可解析", + Subtitle: subtitle, + Metrics: buildFetchMetrics(0, false, hostnameFromURL(input.URL)), + Items: make([]ItemView, 0), + Sections: sections, + Observation: input.Observation, + }) +} + +func buildFetchFailureView( + input FetchViewInput, + rawURL string, + errorMessage string, + payloadMap map[string]any, +) ResultView { + status := classifyUnavailableStatus(errorMessage) + title := "网页抓取失败" + calloutTitle := "抓取执行失败" + tone := "danger" + if status == StatusBlocked { + title = "网页抓取未启用" + calloutTitle = "抓取服务未启用" + tone = "warning" + } + + sections := []map[string]any{ + BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}), + } + appendSectionIfPresent(§ions, BuildArgsSection("抓取参数", buildFetchArgFields(input))) + + return BuildResultView(BuildResultViewInput{ + ViewType: ViewTypeFetchResult, + Status: status, + Title: title, + Subtitle: buildFetchFailureSubtitle(rawURL, errorMessage), + Metrics: buildFetchMetrics(0, false, hostnameFromURL(rawURL)), + Items: make([]ItemView, 0), + Sections: sections, + Observation: input.Observation, + MachinePayload: payloadMap, + }) +} + +func buildFetchMetrics(contentChars int, truncated bool, host string) []MetricField { + metrics := []MetricField{ + BuildMetric("正文长度", fmt.Sprintf("%d 字", contentChars)), + BuildMetric("是否截断", formatBoolCN(truncated)), + } + if strings.TrimSpace(host) != "" { + metrics = append(metrics, BuildMetric("来源", host)) + } + return metrics +} + +func buildFetchArgFields(input FetchViewInput) []KVField { + fields := make([]KVField, 0, 2) + if rawURL := strings.TrimSpace(input.URL); rawURL != "" { + fields = append(fields, BuildKVField("链接", rawURL)) + } + if input.MaxChars > 0 { + fields = append(fields, BuildKVField("截断上限", fmt.Sprintf("%d 字", input.MaxChars))) + } + return fields +} + +func buildFetchPreviewLines(content string) []string { + lines := previewLines(content, 3, 120) + if len(lines) > 0 { + return lines + } + return []string{"正文为空"} +} + +func buildFetchTitle(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "已抓取网页正文" + } + return fmt.Sprintf("已抓取:%s", previewText(title, 36)) +} + +func buildFetchSubtitle(rawURL string, host string) string { + if strings.TrimSpace(host) != "" { + return fmt.Sprintf("来源:%s", host) + } + if strings.TrimSpace(rawURL) != "" { + return fmt.Sprintf("来源:%s", previewText(rawURL, 48)) + } + return "已返回网页正文。" +} + +func buildFetchFailureSubtitle(rawURL string, errorMessage string) string { + if strings.TrimSpace(rawURL) == "" { + return strings.TrimSpace(errorMessage) + } + return fmt.Sprintf("链接:%s", previewText(rawURL, 48)) +} + +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/web_result/search.go b/backend/newAgent/tools/web_result/search.go new file mode 100644 index 0000000..32217ad --- /dev/null +++ b/backend/newAgent/tools/web_result/search.go @@ -0,0 +1,241 @@ +package web_result + +import ( + "encoding/json" + "fmt" + "strings" +) + +type searchObservation struct { + Tool string `json:"tool"` + Query string `json:"query"` + Count int `json:"count"` + Items []searchObservationItem `json:"items"` + Error string `json:"error"` + Err string `json:"err"` + Reason string `json:"reason"` +} + +type searchObservationItem struct { + Title string `json:"title"` + URL string `json:"url"` + Snippet string `json:"snippet"` + Domain string `json:"domain"` + PublishedAt string `json:"published_at"` +} + +// BuildSearchView 负责把 web_search observation 构造成前端可直接消费的结果卡片。 +// +// 职责边界: +// 1. 负责解析成功 / 失败 / provider 未启用 / 非 JSON 回退四类场景。 +// 2. 负责保留 raw_text 与 machine_payload,方便前端调试与后续交互。 +// 3. 不负责执行搜索,也不改写传入 observation 原文。 +func BuildSearchView(input SearchViewInput) ResultView { + payloadMap, ok := parseObservationJSON(input.Observation) + if !ok { + return buildSearchTextFallbackView(input) + } + + payload := searchObservation{} + if err := json.Unmarshal([]byte(strings.TrimSpace(input.Observation)), &payload); err != nil { + return buildSearchTextFallbackView(input) + } + + query := strings.TrimSpace(payload.Query) + if query == "" { + query = strings.TrimSpace(input.Query) + } + + errorMessage := firstNonEmpty(payload.Error, payload.Err, payload.Reason) + if errorMessage == "" { + errorMessage = firstString(payloadMap, "message") + } + if errorMessage != "" { + return buildSearchFailureView(input, query, errorMessage, payloadMap) + } + + items := buildSearchItems(payload.Items) + count := payload.Count + if count < len(items) { + count = len(items) + } + + title := fmt.Sprintf("找到 %d 条网页结果", count) + subtitle := buildSearchSubtitle(query) + sections := make([]map[string]any, 0, 3) + appendSectionIfPresent(§ions, BuildArgsSection("搜索参数", buildSearchArgFields(input))) + if len(items) > 0 { + sections = append(sections, BuildItemsSection("搜索结果", items)) + } else { + sections = append(sections, BuildCalloutSection( + "没有命中结果", + "当前关键词没有返回可展示结果,可以尝试缩短关键词或放宽筛选条件。", + "info", + []string{"建议优先调整关键词,再决定是否继续抓取具体页面。"}, + )) + } + + return BuildResultView(BuildResultViewInput{ + ViewType: ViewTypeSearchResult, + Status: StatusDone, + Title: title, + Subtitle: subtitle, + Metrics: buildSearchMetrics(count, input.DomainAllow, input.RecencyDays), + Items: items, + Sections: sections, + Observation: input.Observation, + MachinePayload: payloadMap, + }) +} + +func buildSearchTextFallbackView(input SearchViewInput) ResultView { + subtitle := "搜索结果不是合法 JSON,已回退为文本预览。" + if strings.TrimSpace(input.Observation) == "" { + subtitle = "搜索工具没有返回结构化结果,已回退为文本预览。" + } + + sections := []map[string]any{ + BuildCalloutSection("结果不可解析", subtitle, "danger", []string{subtitle}), + } + appendSectionIfPresent(§ions, BuildArgsSection("搜索参数", buildSearchArgFields(input))) + appendSectionIfPresent(§ions, buildRawPreviewSection(input.Observation)) + + return BuildResultView(BuildResultViewInput{ + ViewType: ViewTypeSearchResult, + Status: StatusFailed, + Title: "网页搜索结果不可解析", + Subtitle: subtitle, + Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays), + Items: make([]ItemView, 0), + Sections: sections, + Observation: input.Observation, + }) +} + +func buildSearchFailureView( + input SearchViewInput, + query string, + errorMessage string, + payloadMap map[string]any, +) ResultView { + status := classifyUnavailableStatus(errorMessage) + title := "网页搜索失败" + calloutTitle := "搜索执行失败" + tone := "danger" + if status == StatusBlocked { + title = "网页搜索未启用" + calloutTitle = "搜索 Provider 未启用" + tone = "warning" + } + + sections := []map[string]any{ + BuildCalloutSection(calloutTitle, errorMessage, tone, []string{errorMessage}), + } + appendSectionIfPresent(§ions, BuildArgsSection("搜索参数", buildSearchArgFields(input))) + + return BuildResultView(BuildResultViewInput{ + ViewType: ViewTypeSearchResult, + Status: status, + Title: title, + Subtitle: buildSearchFailureSubtitle(query, errorMessage), + Metrics: buildSearchMetrics(0, input.DomainAllow, input.RecencyDays), + Items: make([]ItemView, 0), + Sections: sections, + Observation: input.Observation, + MachinePayload: payloadMap, + }) +} + +func buildSearchItems(items []searchObservationItem) []ItemView { + if len(items) == 0 { + return make([]ItemView, 0) + } + out := make([]ItemView, 0, len(items)) + for index, item := range items { + title := strings.TrimSpace(item.Title) + if title == "" { + title = fmt.Sprintf("结果 %d", index+1) + } + + subtitle := strings.TrimSpace(item.URL) + if domain := strings.TrimSpace(item.Domain); domain != "" { + subtitle = domain + } + + tags := make([]string, 0, 2) + if domain := strings.TrimSpace(item.Domain); domain != "" { + tags = append(tags, domain) + } + if publishedAt := strings.TrimSpace(item.PublishedAt); publishedAt != "" { + tags = append(tags, publishedAt) + } + + detailLines := make([]string, 0, 2) + if snippet := strings.TrimSpace(item.Snippet); snippet != "" { + detailLines = append(detailLines, previewText(snippet, 120)) + } + if rawURL := strings.TrimSpace(item.URL); rawURL != "" { + detailLines = append(detailLines, rawURL) + } + + out = append(out, BuildItem(title, subtitle, tags, detailLines, map[string]any{ + "url": strings.TrimSpace(item.URL), + "domain": strings.TrimSpace(item.Domain), + "published_at": strings.TrimSpace(item.PublishedAt), + })) + } + return out +} + +func buildSearchMetrics(count int, domainAllow []string, recencyDays int) []MetricField { + metrics := []MetricField{ + BuildMetric("结果数", fmt.Sprintf("%d", count)), + } + if len(domainAllow) > 0 { + metrics = append(metrics, BuildMetric("域名过滤", formatStringSliceCN(domainAllow, 2))) + } + if recencyDays > 0 { + metrics = append(metrics, BuildMetric("时效", fmt.Sprintf("近 %d 天", recencyDays))) + } + return metrics +} + +func buildSearchArgFields(input SearchViewInput) []KVField { + fields := make([]KVField, 0, 4) + if query := strings.TrimSpace(input.Query); query != "" { + fields = append(fields, BuildKVField("关键词", query)) + } + if input.TopK > 0 { + fields = append(fields, BuildKVField("结果上限", fmt.Sprintf("%d", input.TopK))) + } + if len(input.DomainAllow) > 0 { + fields = append(fields, BuildKVField("域名过滤", formatStringSliceCN(input.DomainAllow, 4))) + } + if input.RecencyDays > 0 { + fields = append(fields, BuildKVField("时效范围", fmt.Sprintf("近 %d 天", input.RecencyDays))) + } + return fields +} + +func buildSearchSubtitle(query string) string { + if strings.TrimSpace(query) == "" { + return "已返回网页搜索结果。" + } + return fmt.Sprintf("关键词:%s", previewText(query, 40)) +} + +func buildSearchFailureSubtitle(query string, errorMessage string) string { + if strings.TrimSpace(query) == "" { + return strings.TrimSpace(errorMessage) + } + return fmt.Sprintf("关键词:%s", previewText(query, 40)) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/newAgent/tools/web_result/types.go b/backend/newAgent/tools/web_result/types.go new file mode 100644 index 0000000..f9e8b47 --- /dev/null +++ b/backend/newAgent/tools/web_result/types.go @@ -0,0 +1,169 @@ +package web_result + +import "strings" + +const ( + // ViewTypeSearchResult 是 web_search 结果卡片的前端识别类型。 + ViewTypeSearchResult = "web.search_result" + + // ViewTypeFetchResult 是 web_fetch 结果卡片的前端识别类型。 + ViewTypeFetchResult = "web.fetch_result" + + // ViewVersionResult 固定为当前 web 结果卡片结构版本。 + ViewVersionResult = 1 + + // 这里不依赖父包状态常量,避免子包反向 import tools 形成循环依赖。 + StatusDone = "done" + StatusFailed = "failed" + StatusBlocked = "blocked" +) + +// ResultView 是子包暴露给父包 adapter 的纯展示结构。 +// +// 职责边界: +// 1. 负责承载 view_type / version / collapsed / expanded 四段展示数据。 +// 2. 不负责 ToolExecutionResult、SSE、registry 等父包协议。 +// 3. collapsed / expanded 保持 map 形态,便于父包直接桥接现有展示协议。 +type ResultView struct { + ViewType string `json:"view_type"` + Version int `json:"version"` + Collapsed map[string]any `json:"collapsed"` + Expanded map[string]any `json:"expanded"` +} + +// CollapsedView 表示卡片折叠态数据。 +type CollapsedView struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Status string `json:"status"` + StatusLabel string `json:"status_label"` + Metrics []MetricField `json:"metrics"` +} + +// ExpandedView 表示卡片展开态数据。 +type ExpandedView struct { + Items []ItemView `json:"items"` + Sections []map[string]any `json:"sections"` + RawText string `json:"raw_text"` + MachinePayload map[string]any `json:"machine_payload"` +} + +// MetricField 是 collapsed.metrics 的轻量键值结构。 +type MetricField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// KVField 是 section.type=kv 的轻量键值结构。 +type KVField struct { + Label string `json:"label"` + Value string `json:"value"` +} + +// ItemView 是 expanded.items / section.items 的通用结构。 +type ItemView struct { + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Tags []string `json:"tags"` + DetailLines []string `json:"detail_lines"` + Meta map[string]any `json:"meta,omitempty"` +} + +// BuildResultViewInput 是通用 web 结果视图 builder 的输入。 +// +// 职责边界: +// 1. 负责承载已经计算好的标题、副标题、指标、列表、分区。 +// 2. 不负责执行 web 工具;observation 必须由父包 adapter 传入。 +// 3. observation 会原样写入 raw_text,不能在这里改写给模型的观察文本。 +type BuildResultViewInput struct { + ViewType string + Status string + Title string + Subtitle string + Metrics []MetricField + Items []ItemView + Sections []map[string]any + Observation string + MachinePayload map[string]any +} + +// SearchViewInput 是 web_search 视图构造输入。 +type SearchViewInput struct { + Observation string + Query string + TopK int + DomainAllow []string + RecencyDays int +} + +// FetchViewInput 是 web_fetch 视图构造输入。 +type FetchViewInput struct { + Observation string + URL string + MaxChars int +} + +func (view CollapsedView) Map() map[string]any { + metrics := make([]map[string]any, 0, len(view.Metrics)) + for _, metric := range view.Metrics { + label := strings.TrimSpace(metric.Label) + value := strings.TrimSpace(metric.Value) + if label == "" || value == "" { + continue + } + metrics = append(metrics, map[string]any{ + "label": label, + "value": value, + }) + } + if len(metrics) == 0 { + metrics = make([]map[string]any, 0) + } + return map[string]any{ + "title": strings.TrimSpace(view.Title), + "subtitle": strings.TrimSpace(view.Subtitle), + "status": normalizeStatus(view.Status), + "status_label": strings.TrimSpace(view.StatusLabel), + "metrics": metrics, + } +} + +func (view ExpandedView) Map() map[string]any { + items := make([]map[string]any, 0, len(view.Items)) + for _, item := range view.Items { + items = append(items, item.Map()) + } + if len(items) == 0 { + items = make([]map[string]any, 0) + } + + sections := cloneSectionList(view.Sections) + if len(sections) == 0 { + sections = make([]map[string]any, 0) + } + + machinePayload := cloneAnyMap(view.MachinePayload) + if machinePayload == nil { + machinePayload = make(map[string]any) + } + + return map[string]any{ + "items": items, + "sections": sections, + "raw_text": view.RawText, + "machine_payload": machinePayload, + } +} + +func (view ItemView) Map() map[string]any { + out := map[string]any{ + "title": strings.TrimSpace(view.Title), + "subtitle": strings.TrimSpace(view.Subtitle), + "tags": normalizeStringSlice(view.Tags), + "detail_lines": normalizeStringSlice(view.DetailLines), + } + if len(view.Meta) > 0 { + out["meta"] = cloneAnyMap(view.Meta) + } + return out +} diff --git a/backend/newAgent/tools/web_result_handlers.go b/backend/newAgent/tools/web_result_handlers.go new file mode 100644 index 0000000..de972d2 --- /dev/null +++ b/backend/newAgent/tools/web_result_handlers.go @@ -0,0 +1,191 @@ +package newagenttools + +import ( + "strings" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" + "github.com/LoveLosita/smartflow/backend/newAgent/tools/web" + webresult "github.com/LoveLosita/smartflow/backend/newAgent/tools/web_result" +) + +// NewWebSearchToolHandler 返回 web_search 的结构化结果 handler。 +// +// 职责边界: +// 1. 负责执行底层 web_search 工具,并保留原始 ObservationText 给模型。 +// 2. 负责把工具参数投影成 web_result 子包需要的最小输入。 +// 3. 不负责注册接线;registry.go 由主代理统一切流。 +func NewWebSearchToolHandler(provider web.SearchProvider) ToolHandler { + searchHandler := web.NewSearchToolHandler(provider) + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + observation := searchHandler.Handle(args) + legacy := LegacyResultWithState("web_search", args, state, observation) + + view := webresult.BuildSearchView(webresult.SearchViewInput{ + Observation: observation, + Query: readStringArg(args, "query"), + TopK: readIntArg(args, "top_k"), + DomainAllow: readStringSliceArg(args, "domain_allow"), + RecencyDays: readIntArg(args, "recency_days"), + }) + return buildWebExecutionResult(legacy, args, view) + } +} + +// NewWebFetchToolHandler 返回 web_fetch 的结构化结果 handler。 +// +// 职责边界: +// 1. 负责执行底层 web_fetch 工具,并保留原始 ObservationText 给模型。 +// 2. 负责把抓取参数投影成 web_result 子包需要的最小输入。 +// 3. 不负责注册接线;registry.go 由主代理统一切流。 +func NewWebFetchToolHandler(fetcher *web.Fetcher) ToolHandler { + fetchHandler := web.NewFetchToolHandler(fetcher) + return func(state *schedule.ScheduleState, args map[string]any) ToolExecutionResult { + observation := fetchHandler.Handle(args) + legacy := LegacyResultWithState("web_fetch", args, state, observation) + + view := webresult.BuildFetchView(webresult.FetchViewInput{ + Observation: observation, + URL: readStringArg(args, "url"), + MaxChars: readIntArg(args, "max_chars"), + }) + return buildWebExecutionResult(legacy, args, view) + } +} + +// buildWebExecutionResult 负责把子包纯展示视图包回父包统一协议。 +// +// 步骤化说明: +// 1. 先以 legacy 结果为基础,复用父包现有的参数预览、错误抽取与兜底字段。 +// 2. 再用子包 collapsed.status 覆盖最终状态,支持“未启用 provider -> blocked”的卡片语义。 +// 3. 最后补齐 raw_text / status_label,保证 execute、SSE、timeline 都消费同一份 observation。 +func buildWebExecutionResult( + legacy ToolExecutionResult, + args map[string]any, + view webresult.ResultView, +) ToolExecutionResult { + result := legacy + status := normalizeToolStatus(result.Status) + if status == "" { + status = ToolStatusDone + } + if collapsedStatus, ok := readStringAnyMap(view.Collapsed, "status"); ok { + if normalized := normalizeToolStatus(collapsedStatus); normalized != "" { + status = normalized + } + } + + collapsed := cloneAnyMap(view.Collapsed) + if collapsed == nil { + collapsed = make(map[string]any) + } + expanded := cloneAnyMap(view.Expanded) + if expanded == nil { + expanded = make(map[string]any) + } + + collapsed["status"] = status + if _, exists := collapsed["status_label"]; !exists { + collapsed["status_label"] = resolveToolStatusLabelCN(status) + } + if _, exists := expanded["raw_text"]; !exists { + expanded["raw_text"] = result.ObservationText + } + if _, exists := expanded["machine_payload"]; !exists { + expanded["machine_payload"] = map[string]any{} + } + + viewType := strings.TrimSpace(view.ViewType) + if viewType == "" { + viewType = webresult.ViewTypeSearchResult + } + version := view.Version + if version <= 0 { + version = webresult.ViewVersionResult + } + + result.Status = status + result.Success = status == ToolStatusDone + result.ResultView = &ToolDisplayView{ + ViewType: viewType, + Version: version, + Collapsed: collapsed, + Expanded: expanded, + } + if title, ok := readStringAnyMap(collapsed, "title"); ok { + result.Summary = title + } + if !result.Success { + errorCode, errorMessage := extractToolErrorInfo(result.ObservationText, status) + if strings.TrimSpace(result.ErrorCode) == "" { + result.ErrorCode = strings.TrimSpace(errorCode) + } + if strings.TrimSpace(result.ErrorMessage) == "" { + result.ErrorMessage = strings.TrimSpace(errorMessage) + } + } + return EnsureToolResultDefaults(result, args) +} + +func readStringArg(args map[string]any, key string) string { + if len(args) == 0 { + return "" + } + raw, exists := args[strings.TrimSpace(key)] + if !exists || raw == nil { + return "" + } + text, ok := raw.(string) + if !ok { + return "" + } + return strings.TrimSpace(text) +} + +func readIntArg(args map[string]any, key string) int { + if len(args) == 0 { + return 0 + } + value, ok := toInt(args[strings.TrimSpace(key)]) + if !ok { + return 0 + } + return value +} + +func readStringSliceArg(args map[string]any, key string) []string { + if len(args) == 0 { + return nil + } + raw, exists := args[strings.TrimSpace(key)] + if !exists || raw == nil { + return nil + } + switch typed := raw.(type) { + case []string: + out := make([]string, 0, len(typed)) + for _, item := range typed { + item = strings.TrimSpace(item) + if item == "" { + continue + } + out = append(out, item) + } + return out + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if !ok { + continue + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + out = append(out, text) + } + return out + default: + return nil + } +} diff --git a/backend/newAgent/tools/工具结果结构化交接文档.md b/backend/newAgent/tools/工具结果结构化交接文档.md index b6a5be4..8423e69 100644 --- a/backend/newAgent/tools/工具结果结构化交接文档.md +++ b/backend/newAgent/tools/工具结果结构化交接文档.md @@ -1,5 +1,100 @@ # 工具结果结构化交接文档 +## 最新负责人验收结论 + +本轮已经按“直接切流”完成 read 结果构造迁移:6 个 `schedule.read_result` 工具仍由父包注册入口暴露,但父包只保留薄 adapter,真实展示数据构造已经切到 `backend/newAgent/tools/schedule_read/` 子包。 + +第三批 `schedule.analysis_result` 也已经完成直接切流:`analyze_health` / `analyze_rhythm` 仍然保留原始 JSON `ObservationText`,前端展示新增走 `ResultView`。 + +第四批非 schedule read/analysis 主链也已经完成直接切流:`web_search`、`web_fetch`、`upsert_task_class`、`context_tools_add`、`context_tools_remove` 已经分别切到专属结构化卡片;队列尾巴 `queue_pop_head` / `queue_skip_head` 也已经脱离 `legacy_text`。 + +第四批当前切流点: + +1. `registry.go` + - `web_search` 已切到 `NewWebSearchToolHandler()`。 + - `web_fetch` 已切到 `NewWebFetchToolHandler()`。 + - `upsert_task_class` 继续注册 `NewTaskClassUpsertToolHandler()`,但该入口已由 `taskclass_result_handlers.go` 承接结构化包装。 + - `context_tools_add` / `context_tools_remove` 已在原 handler 内直接返回 `tool.context_result`。 + - `queue_pop_head` 已切到 `NewQueuePopHeadToolHandler()`。 + - `queue_skip_head` 已切到 `NewQueueSkipHeadToolHandler()`。 + - `wrapLegacyToolHandler` 已删除,当前默认注册表不再依赖该 legacy wrapper。 +2. `backend/newAgent/tools/web_result/**` + `web_result_handlers.go` + - `web_search` 输出 `result_view.view_type = "web.search_result"`。 + - `web_fetch` 输出 `result_view.view_type = "web.fetch_result"`。 + - 原始 observation 继续写入 `ObservationText` / `expanded.raw_text`。 +3. `backend/newAgent/tools/taskclass_result/**` + `taskclass_result_handlers.go` + - `upsert_task_class` 输出 `result_view.view_type = "taskclass.write_result"`。 + - 写库、confirm、校验、错误处理语义不变,只替换展示层。 +4. `backend/newAgent/tools/tool_context_result/**` + `context_tools.go` + - `context_tools_add` / `context_tools_remove` 输出 `result_view.view_type = "tool.context_result"`。 + - 卡片展示 domain、packs、mode、all 和失败原因。 +5. `backend/newAgent/tools/schedule_queue_handlers.go` + - `queue_pop_head` 复用 `schedule.read_result`。 + - `queue_skip_head` 复用 `schedule.operation_result`。 + +第四批验收结果: + +1. `go test ./newAgent/tools/...` 通过。 +2. `go test ./...` 通过。 +3. 根目录 `.gocache` 已清理。 +4. 没有遗留临时 `*_test.go`。 +5. `ObservationText` 均保持原始工具 observation,不被展示层改写。 + +analysis 当前切流点: + +1. `registry.go` + - `analyze_health` 已切到 `NewAnalyzeHealthToolHandler()`。 + - `analyze_rhythm` 已切到 `NewAnalyzeRhythmToolHandler()`。 +2. `backend/newAgent/tools/schedule_analysis_handlers.go` + - 父包唯一 analysis adapter 文件。 + - 负责执行 `schedule.AnalyzeHealth` / `schedule.AnalyzeRhythm`,保留原始 `ObservationText`,生成中文 `ArgumentView`,再调用 `schedule_analysis.BuildAnalyzeHealthView()` / `BuildAnalyzeRhythmView()`。 +3. `backend/newAgent/tools/schedule_analysis/**` + - 子包负责纯 analysis 展示数据构造。 + - 不 import 父包 `newagenttools`,不返回 `ToolExecutionResult`。 + +analysis 验收结果: + +1. `result_view.view_type = "schedule.analysis_result"`,`version = 1`。 +2. `expanded.raw_text` 保留原始 observation JSON。 +3. `expanded.machine_payload` 保留解析后的完整机器字段,供调试和后续交互使用。 +4. `analyze_health` 的 observation JSON 契约未改动,仍可被 `state_snapshot` / prompt 摘要消费。 +5. `go test ./newAgent/tools/...` 通过。 +6. `go test ./...` 通过。 +7. 根目录 `.gocache` 已清理。 +8. 没有遗留临时 `*_test.go`。 + +当前切流点: + +1. `registry.go` + - 继续注册 `NewQueryAvailableSlotsToolHandler()`、`NewQueryRangeToolHandler()`、`NewQueryTargetTasksToolHandler()`、`NewGetTaskInfoToolHandler()`、`NewGetOverviewToolHandler()`、`NewQueueStatusToolHandler()`。 +2. `backend/newAgent/tools/schedule_read_handlers.go` + - 父包唯一 read adapter 文件。 + - 负责执行底层 `schedule.*` 工具,保留原始 `ObservationText`,生成中文 `ArgumentView`,再调用 `schedule_read.BuildXxxView()`。 +3. `backend/newAgent/tools/schedule_read/**` + - 子包负责纯 read 展示数据构造。 + - 不 import 父包 `newagenttools`,不返回 `ToolExecutionResult`。 + +已删除旧实现: + +1. `schedule_read_result_types.go` +2. `schedule_read_result_common.go` +3. `schedule_read_slots_handlers.go` +4. `schedule_read_tasks_handlers.go` +5. `schedule_read_overview_queue_handlers.go` + +仍保留的父包能力: + +1. `schedule_argument_format_helpers.go` + - 只服务 `execution_result.go` 的参数中文展示。 + - 不再参与 `schedule.read_result` 卡片构造。 + +验收结果: + +1. `go test ./newAgent/tools/...` 通过。 +2. `go test ./...` 通过。 +3. 根目录 `.gocache` 已清理。 +4. 没有遗留临时 `*_test.go`。 + ## 当前状态 本轮已经完成第二批 read 事实域工具的后端结构化结果改造。外层协议仍然是 `ToolExecutionResult`,LLM 观察文本继续走 `ObservationText`,前端展示信息走 `ResultView` / `ArgumentView`。 @@ -206,26 +301,29 @@ schedule.analysis_result ## 后续批次 -第四批建议处理非 schedule read/analysis 主链: +当前文档计划内的四批结构化结果已经完成。默认注册表里主链工具已经不再通过 `wrapLegacyToolHandler` 接入。 -1. `web_search` -2. `web_fetch` -3. `upsert_task_class` -4. `context_tools_add` -5. `context_tools_remove` +当前稳定 view type: -可考虑的 view type: +1. `schedule.operation_result` +2. `schedule.read_result` +3. `schedule.analysis_result` +4. `web.search_result` +5. `web.fetch_result` +6. `taskclass.write_result` +7. `tool.context_result` +8. `legacy_text` -1. `web.search_result` -2. `web.fetch_result` -3. `taskclass.write_result` -4. `tool.context_result` +`legacy_text` 仍作为未知工具、兜底结果或未来新增工具的保底协议保留,不建议删除。 -这些工具不建议混入 `schedule.read_result` 或 `schedule.analysis_result`,否则前端语义会越来越模糊。 +若后续继续整理,建议只做两类小收尾: + +1. 前端补齐 `web.search_result`、`web.fetch_result`、`taskclass.write_result`、`tool.context_result` 的专项 mock 与视觉验收。 +2. 评估是否把各子包重复的 `kv/items/callout` helper 下沉到中性公共包,例如 `backend/newAgent/tools/toolview/`;只有在确认 read、analysis、web、taskclass、context 都稳定后再抽,避免提前扩大回归面。 ## 前端补丁提示 -第二批后端已经新增 `schedule.read_result`。前端最低要求: +后端当前已经输出多种结构化 `result_view`。前端最低要求: 1. 头部继续优先读: - `result_view.collapsed.title` @@ -236,13 +334,23 @@ schedule.analysis_result - 支持 `expanded.items` - 支持 `expanded.sections` - section 类型至少兼容 `items`、`kv`、`callout` + - `callout` 需要兼容 `subtitle` 和 `summary` 3. 默认不展示: - `expanded.machine_payload` - `items[].meta` - 原始英文 key/value 4. `raw_text` 只放 debug 折叠区。 -前端如果暂时不识别 `schedule.read_result`,至少能显示折叠态;但展开态会退回 raw text,不符合 C 端目标。 +前端需要至少识别以下新协议: + +1. `schedule.read_result` +2. `schedule.analysis_result` +3. `web.search_result` +4. `web.fetch_result` +5. `taskclass.write_result` +6. `tool.context_result` + +前端如果暂时不识别某个专属 view type,至少要显示折叠态;但展开态退回 raw text 不符合 C 端目标。 ## 验收清单 diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index bc2e5ff..236e60a 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -738,6 +738,9 @@ function appendToolTraceEvent( matchedPendingEvent.summary = normalizedSummary matchedPendingEvent.detail = normalizedDetail || matchedPendingEvent.detail matchedPendingEvent.toolName = normalizedToolName || matchedPendingEvent.toolName + // 同步更新视图模型,确保 tool_result 的 result_view 能回填到已存在的卡片中 + if (argumentView) matchedPendingEvent.argumentView = argumentView + if (resultView) matchedPendingEvent.resultView = resultView return } const eventSeq = nextAssistantTimelineSeq() @@ -3209,7 +3212,7 @@ onBeforeUnmount(() => {
-
+
+ +
{{ m.value }} {{ m.label }} @@ -95,23 +95,32 @@ function getOperationFallbackLabel(op: string) {
- -
-

参数详情

-

- {{ payload.argument_view.collapsed.summary }} -

-
-
- {{ f.label }} - {{ f.display }} + +
+
+ + 输入参数 + {{ payload.argument_view.collapsed?.summary }} + +
+
+ {{ field.label }} + {{ field.display || field.value }}
-
+
+
-

操作结果

+

操作变更明细

+ + +
+ 影响日期: + {{ payload.result_view.expanded.affected_days_label }} +
+
@@ -119,14 +128,14 @@ function getOperationFallbackLabel(op: string) { {{ change.task_label }} {{ change.status_label }}
- +
之前
{{ change.before_label || '未排程' }}
- + @@ -138,7 +147,7 @@ function getOperationFallbackLabel(op: string) {
- +
{{ payload.result_view.expanded.queue_snapshot.summary_label || '队列变更' }}
@@ -152,7 +161,7 @@ function getOperationFallbackLabel(op: string) {
- +
! @@ -160,7 +169,94 @@ function getOperationFallbackLabel(op: string) {
- + +
+ + + + + +
+ +

{{ payload.result_view.expanded?.raw_text_label || '输出内容' }}

@@ -168,7 +264,21 @@ function getOperationFallbackLabel(op: string) {
- + +
+

结果详情 (未知协议)

+
+

{{ payload.result_view.collapsed.subtitle }}

+
+
+ {{ m.label }}: + {{ m.value }} +
+
+
+
+ +

工具输出 (兼容模式)

@@ -197,9 +307,14 @@ function getOperationFallbackLabel(op: string) { border: 1px solid #eef2f6; border-radius: 16px; overflow: hidden; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow-x: hidden; + transition: border-color 0.3s, box-shadow 0.3s, transform 0.3s, background-color 0.3s; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02); margin: 8px 0; + width: 100%; + box-sizing: border-box; + min-width: 0; + overflow-wrap: break-word; } .tool-card:hover { @@ -227,6 +342,9 @@ function getOperationFallbackLabel(op: string) { gap: 12px; cursor: pointer; user-select: none; + width: 100%; + box-sizing: border-box; + min-width: 0; } .tool-card__icon-box { @@ -344,6 +462,10 @@ function getOperationFallbackLabel(op: string) { /* Content */ .tool-card__content { padding: 0 16px 20px; + min-width: 0; + width: 100%; + box-sizing: border-box; + overflow: hidden; } .tool-card__divider { @@ -365,41 +487,56 @@ function getOperationFallbackLabel(op: string) { letter-spacing: 0.05em; } -/* Arguments */ -.arg-summary { - font-size: 13px; - color: #475569; - font-weight: 500; - line-height: 1.5; - margin-bottom: 10px; +/* Arguments Section */ +.view-arguments { + margin-bottom: 16px; + padding: 0 4px; } -.arg-fields { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; - background: #f8fafc; - padding: 12px; - border-radius: 12px; +.arguments-details { border: 1px solid #f1f5f9; + border-radius: 10px; + background: #f8fafc; + overflow: hidden; } -.arg-field-item { +.arguments-summary { + padding: 10px 12px; + cursor: pointer; display: flex; - flex-direction: column; - gap: 1px; + align-items: center; + gap: 8px; + list-style: none; + user-select: none; } -.arg-label { - font-size: 10px; - color: #94a3b8; - font-weight: 500; +.arguments-summary::-webkit-details-marker { + display: none; } -.arg-value { +.arguments-summary .summary-label { font-size: 12px; - color: #1e293b; font-weight: 600; + color: #64748b; + background: #fff; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid #e2e8f0; +} + +.arguments-summary .summary-preview { + flex: 1; + font-size: 12px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.kv-grid--arguments { + padding: 8px 12px 12px; + border-top: 1px solid #f1f5f9; + background: #fff; } /* Operation Changes */ @@ -434,6 +571,7 @@ function getOperationFallbackLabel(op: string) { font-size: 13px; font-weight: 700; color: #0f172a; + word-break: break-all; } .change-item__status-tag { @@ -455,6 +593,8 @@ function getOperationFallbackLabel(op: string) { flex: 1; padding: 8px 10px; border-radius: 10px; + min-width: 0; + overflow: hidden; } .slot-box--before { @@ -480,6 +620,7 @@ function getOperationFallbackLabel(op: string) { .slot-text { font-size: 12px; font-weight: 600; + word-break: break-all; } /* Queue Snapshot */ @@ -518,10 +659,59 @@ function getOperationFallbackLabel(op: string) { color: #10b981; } +/* Operation View Styles */ +.affected-days-box { + margin: -4px 0 16px; + padding: 10px 14px; + background: #f8fafc; + border-radius: 12px; + border: 1px solid #f1f5f9; + display: flex; + align-items: center; + gap: 8px; +} + +.affected-label { + font-size: 11px; + font-weight: 700; + color: #94a3b8; +} + +.affected-value { + font-size: 12px; + font-weight: 600; + color: #334155; +} + +.path-arrow { + color: #cbd5e1; + display: flex; + align-items: center; + justify-content: center; + width: 24px; +} + +.tool-card--expanded .path-arrow { + color: #3b82f6; + filter: drop-shadow(0 0 4px rgba(59, 130, 246, 0.2)); +} + .queue-arrow { flex: 2; height: 2px; background: #e2e8f0; + position: relative; +} + +.queue-arrow::after { + content: ''; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + border-left: 6px solid #e2e8f0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; } /* Legacy Text */ @@ -617,6 +807,221 @@ function getOperationFallbackLabel(op: string) { overflow-y: auto; } +/* Read Result Styles */ +.read-section + .read-section { + margin-top: 24px; +} + +.section-summary { + font-size: 13px; + color: #475569; + line-height: 1.6; + margin: -4px 0 12px; +} + +.items-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.list-item-card { + background: #f8fafc; + border: 1px solid #f1f5f9; + border-radius: 12px; + padding: 12px 14px; + transition: all 0.2s ease; + min-width: 0; + box-sizing: border-box; +} + +.list-item-card:hover { + background: #f1f5f9; + border-color: #e2e8f0; +} + +.item-main { + display: flex; + flex-direction: column; + gap: 2px; +} + +.item-title { + font-size: 14px; + font-weight: 700; + color: #1e293b; + word-break: break-all; +} + +.item-subtitle { + font-size: 12px; + color: #64748b; + font-weight: 500; +} + +.item-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.item-tag { + font-size: 10px; + font-weight: 600; + color: #3b82f6; + background: #eff6ff; + padding: 1px 8px; + border-radius: 6px; + border: 1px solid #dbeafe; +} + +.item-details { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed #e2e8f0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.detail-line { + font-size: 12px; + color: #475569; + margin: 0; + line-height: 1.5; + word-break: break-word; +} + +/* KV Grid */ +.kv-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 140px), 1fr)); + gap: 12px; + background: #f8fafc; + padding: 16px; + border-radius: 14px; + border: 1px solid #f1f5f9; +} + +.kv-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + overflow: hidden; +} + +.kv-label { + font-size: 10px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.kv-value { + flex: 1; + min-width: 0; + font-size: 13px; + color: #1e293b; + font-weight: 500; + text-align: left; + word-break: break-word; + line-height: 1.4; +} + +/* Callout */ +.read-section__callout { + padding: 14px 16px; + border-radius: 14px; + border-left: 4px solid #cbd5e1; + background: #f8fafc; + min-width: 0; + box-sizing: border-box; +} + +.read-section__callout.tone--info { + background: #f0f9ff; + border-color: #3b82f6; +} + +.read-section__callout.tone--info .callout-title { color: #0369a1; } +.read-section__callout.tone--info .callout-summary { color: #0c4a6e; } + +.read-section__callout.tone--warning { + background: #fffbeb; + border-color: #f59e0b; +} + +.read-section__callout.tone--warning .callout-title { color: #b45309; } +.read-section__callout.tone--warning .callout-summary { color: #78350f; } + +.read-section__callout.tone--danger { + background: #fef2f2; + border-color: #ef4444; +} + +.read-section__callout.tone--danger .callout-title { color: #b91c1c; } +.read-section__callout.tone--danger .callout-summary { color: #7f1d1d; } + +.callout-header { + margin-bottom: 4px; +} + +.callout-title { + font-size: 13px; + font-weight: 800; + word-break: break-word; +} + +.callout-summary { + font-size: 12px; + margin: 0; + line-height: 1.5; + font-weight: 500; + word-break: break-word; +} + +.callout-details { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(0,0,0,0.05); +} + +/* Unknown View Styles */ +.unknown-subtitle { + font-size: 13px; + color: #475569; + margin-bottom: 12px; + line-height: 1.5; +} + +.unknown-metrics { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.metric-chip { + background: #f1f5f9; + padding: 4px 10px; + border-radius: 8px; + font-size: 11px; + display: flex; + gap: 4px; +} + +.chip-label { + color: #64748b; + font-weight: 600; +} + +.chip-value { + color: #0f172a; + font-weight: 700; +} + /* Animations */ .tool-expand-enter-active, .tool-expand-leave-active { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 22c72c6..800c837 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -45,6 +45,16 @@ const router = createRouter({ requiresAuth: true, }, }, + { + path: '/debug/tool-card', + name: 'debug-tool-card', + component: () => import('@/views/debug/ToolCardFixture.vue'), + }, + { + path: '/debug/tool-cards', + name: 'debug-tool-cards', + component: () => import('@/views/debug/ToolCardMockPage.vue'), + }, ], }) diff --git a/frontend/src/views/debug/ToolCardFixture.vue b/frontend/src/views/debug/ToolCardFixture.vue new file mode 100644 index 0000000..09d3146 --- /dev/null +++ b/frontend/src/views/debug/ToolCardFixture.vue @@ -0,0 +1,632 @@ + + + + + diff --git a/frontend/src/views/debug/ToolCardMockPage.vue b/frontend/src/views/debug/ToolCardMockPage.vue new file mode 100644 index 0000000..bdeddad --- /dev/null +++ b/frontend/src/views/debug/ToolCardMockPage.vue @@ -0,0 +1,481 @@ + + + + +