diff --git a/internal/genui/genui.go b/internal/genui/genui.go index ebcf0a7..1c0ae1e 100644 --- a/internal/genui/genui.go +++ b/internal/genui/genui.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "strings" "agent-system-backend/pkg/extension" @@ -276,10 +277,118 @@ func normalizeSpecParam(v any) any { } } repairChildrenShape(m) + // {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}} + // 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进 + // 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。 + if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok { + m = unwrapped + } repairPairs(m) + repairTableCells(m) return m } +// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12 +// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…) +// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。 +// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义, +// 安全解包;只改结构不改内容。 +func repairItemWrappers(v any) any { + switch t := v.(type) { + case map[string]any: + if len(t) == 1 { + if inner, ok := t["item"]; ok { + return repairItemWrappers(inner) + } + } + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = repairItemWrappers(val) + } + return out + case []any: + out := make([]any, len(t)) + for i, item := range t { + out[i] = repairItemWrappers(item) + } + return out + default: + return v + } +} + +// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与 +// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。 +// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动, +// 交校验器给字段级错误供模型修复重提。 +func repairTableCells(node any) { + m, ok := node.(map[string]any) + if !ok { + return + } + if tbl, ok := m["table"].(map[string]any); ok { + cols, colsOK := tbl["columns"].([]any) + rows, rowsOK := tbl["rows"].([]any) + if colsOK && rowsOK { + keys := make([]string, len(cols)) + valid := true + for i, c := range cols { + cm, ok := c.(map[string]any) + if !ok { + valid = false + break + } + k, ok := cm["key"].(string) + if !ok || k == "" { + valid = false + break + } + keys[i] = k + } + if valid { + for _, r := range rows { + rm, ok := r.(map[string]any) + if !ok { + continue + } + cells, ok := rm["cells"].([]any) + if !ok || len(cells) != len(keys) { + continue + } + paired := make([]any, 0, len(cells)) + convertible := true + for i, c := range cells { + switch cv := c.(type) { + case string: + paired = append(paired, []any{keys[i], cv}) + case float64: + paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)}) + default: + convertible = false + break + } + } + if convertible { + rm["cells"] = paired + } + } + } + } + } + for _, v := range m { + if vm, ok := v.(map[string]any); ok { + repairTableCells(vm) + } + if va, ok := v.([]any); ok { + for _, item := range va { + if im, ok := item.(map[string]any); ok { + repairTableCells(im) + } + } + } + } +} + // repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测): // children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。 func repairChildrenShape(node any) { diff --git a/internal/genui/genui_test.go b/internal/genui/genui_test.go index d1e1908..1857ab8 100644 --- a/internal/genui/genui_test.go +++ b/internal/genui/genui_test.go @@ -297,3 +297,76 @@ func TestNormalizeChildrenShape(t *testing.T) { t.Fatalf("children should be repaired to array, got %v", ch) } } + +// 真实偏差(2026-10-12 dev 实测,事件 seq 1395/2171):MiniMax-M3 对数组字段输出 +// XML-RPC 风格 {"item": …} 包装——kv/cells/items 整字段被包一层 {"item":[…]}, +// 数组元素再被包 {"item":["k","v"]}/{"item":"v"};表格行 cells 另有与列等长的 +// 裸标量数组形态。修复层须解包并按列 key 配对,present 才能落库。 +func TestPresentRepairsItemWrappers(t *testing.T) { + newRealSvc(t) + h := &fakeHost{enabled: true, epoch: 1} + spec := map[string]any{ + "title": "本月支出结构分析(示例数据演示)", + "nodes": []any{ + map[string]any{"type": "markdown", "id": "disclaimer", "markdown": "示例数据,非真实账单。"}, + map[string]any{"type": "kv", "id": "kv1", "kv": map[string]any{"item": []any{ + map[string]any{"item": []any{"月份", "2026-10"}}, + map[string]any{"item": []any{"总支出", "¥10,000"}}, + }}}, + map[string]any{"type": "list", "id": "l1", "items": map[string]any{"item": []any{ + map[string]any{"item": "餐饮 35%"}, + map[string]any{"item": "住房 25%"}, + }}}, + map[string]any{"type": "table", "id": "t1", "table": map[string]any{ + "columns": []any{ + map[string]any{"key": "cat", "label": "分类"}, + map[string]any{"key": "amt", "label": "金额"}, + map[string]any{"key": "pct", "label": "占比"}, + }, + "rows": []any{ + map[string]any{"cells": map[string]any{"item": []any{ + map[string]any{"item": "餐饮"}, map[string]any{"item": "¥3,500"}, map[string]any{"item": "35%"}, + }}}, + }, + }}, + }, + } + res, err := present(context.Background(), h, reqWith(spec)) + if err != nil { + t.Fatalf("present: %v", err) + } + if body, ok := res.(toolErrorBody); ok { + t.Fatalf("present should repair item wrappers, got %s %v", body.Error, body.Details) + } + if len(h.saved) != 1 { + t.Fatalf("expected one saved surface, got %d", len(h.saved)) + } + var saved core.SurfaceSpec + if err := json.Unmarshal(h.saved[0].Spec, &saved); err != nil { + t.Fatalf("saved spec should unmarshal: %v", err) + } + if saved.Nodes[1].KV[0] != [2]string{"月份", "2026-10"} || saved.Nodes[1].KV[1][1] != "¥10,000" { + t.Fatalf("kv pairs should be unwrapped, got %v", saved.Nodes[1].KV) + } + if len(saved.Nodes[2].Items) != 2 || saved.Nodes[2].Items[0] != "餐饮 35%" { + t.Fatalf("list items should be unwrapped, got %v", saved.Nodes[2].Items) + } + cells := saved.Nodes[3].Table.Rows[0].Cells + if len(cells) != 3 || cells[0] != [2]string{"cat", "餐饮"} || cells[2][1] != "35%" { + t.Fatalf("table cells should pair with column keys, got %v", cells) + } +} + +// 长度不匹配的裸 cells 不做发明性配对:保留原样交校验器给字段级错误。 +func TestRepairTableCellsLeavesMismatchedShapes(t *testing.T) { + in := map[string]any{"type": "table", "id": "t1", "table": map[string]any{ + "columns": []any{map[string]any{"key": "a"}, map[string]any{"key": "b"}}, + "rows": []any{map[string]any{"cells": []any{"x", "y", "z"}}}, + }} + out := normalizeSpecParam(map[string]any{"title": "t", "nodes": []any{in}}) + rows := out.(map[string]any)["nodes"].([]any)[0].(map[string]any)["table"].(map[string]any)["rows"].([]any) + got := rows[0].(map[string]any)["cells"].([]any) + if len(got) != 3 || got[0] != "x" { + t.Fatalf("mismatched scalars should stay untouched, got %v", got) + } +} diff --git a/internal/prompts/library/agent-sub-general.md b/internal/prompts/library/agent-sub-general.md index 1910534..0b05e17 100644 --- a/internal/prompts/library/agent-sub-general.md +++ b/internal/prompts/library/agent-sub-general.md @@ -1,10 +1,10 @@ --- id: agent.sub.general layer: agent -version: 2 +version: 3 title: 通用子代理 description: 独立完成子任务并返回结构化结果(默认子代理提示词) -tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, generative-ui.present model: lightweight --- 你是被派生的子代理,负责独立完成一个子任务。 @@ -13,3 +13,4 @@ model: lightweight - 把任务完整做完:既不过度修饰,也不半途而废;遇到障碍先自行尝试解决,确实无法解决时在结果中说明卡点与已尝试的手段。 - 只使用与任务相关的信息,不臆测派生方未提供的上下文。 - 完成时返回结构化结果:做了什么、关键发现/结论、产出物位置(如有)、遗留问题(如有)。分点列出,关键内容不省略;摘要长度服从内容,不为简短而丢信息。 +- 页面化产物(分析页/对比页等)用 `generative-ui.present` 提交;工具不可用或提交被拒时改用 `write_artifact` 落盘并在结果中说明。