result.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. package kit
  2. import (
  3. "bytes"
  4. "encoding/csv"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "reflect"
  9. "strings"
  10. "unicode/utf8"
  11. "github.com/mark3labs/mcp-go/mcp"
  12. "fwh-toolbox-server/utils/pii"
  13. )
  14. // MaxResultChars 单次结果(非导出)的最大字符数。低于 AgentServer 网关的 10 万字截断阈值,
  15. // 避免触发"前 10 万字仍进上下文"的路径。
  16. const MaxResultChars = 30000
  17. // MaxExportBytes 导出文件上限。
  18. const MaxExportBytes = 20 << 20
  19. // ---- 错误 ----
  20. // Fail 返回给模型的业务错误(参数不对、无数据等)。
  21. func Fail(err error) *mcp.CallToolResult {
  22. var ae *ArgError
  23. if errors.As(err, &ae) {
  24. return mcp.NewToolResultError(ae.Msg)
  25. }
  26. return mcp.NewToolResultError(err.Error())
  27. }
  28. // Failf 以格式化文本返回错误。
  29. func Failf(format string, a ...any) *mcp.CallToolResult {
  30. return mcp.NewToolResultError(fmt.Sprintf(format, a...))
  31. }
  32. // Upstream 上游(FWH 平台、数据库、Plane 等)调用失败。
  33. func Upstream(source string, err error) *mcp.CallToolResult {
  34. return mcp.NewToolResultError(fmt.Sprintf("调用%s失败:%v。可以稍后重试,或缩小查询范围。", source, err))
  35. }
  36. // Empty 无数据时的统一返回(不是错误)。
  37. func Empty(msg string) *mcp.CallToolResult {
  38. if msg == "" {
  39. msg = "没有查询到数据。"
  40. }
  41. return mcp.NewToolResultText(msg)
  42. }
  43. // ---- 对象与列表 ----
  44. // Object 单个对象:脱敏、去掉空值后以紧凑 JSON 返回。
  45. func Object(v any) *mcp.CallToolResult {
  46. tree, err := sanitize(v)
  47. if err != nil {
  48. return mcp.NewToolResultError(err.Error())
  49. }
  50. b, _ := json.Marshal(tree)
  51. text := string(b)
  52. if utf8.RuneCountInString(text) > MaxResultChars {
  53. text = truncateRunes(text, MaxResultChars) + "\n(结果过长已截断,请缩小查询范围)"
  54. }
  55. return mcp.NewToolResultText(text)
  56. }
  57. // ListResult 列表结果的结构。
  58. type ListResult struct {
  59. Total int `json:"total"`
  60. Returned int `json:"returned"`
  61. Truncated bool `json:"truncated"`
  62. Hint string `json:"hint,omitempty"`
  63. Items []any `json:"items"`
  64. }
  65. // List 列表:脱敏、去空值,最多返回 limit 条;序列化后超过 MaxResultChars 时继续减少条数。
  66. // items 必须是切片(nil 视为空);total < 0 时以切片长度作为总数。
  67. func List(items any, total, limit int, hint string) *mcp.CallToolResult {
  68. tree, err := sanitize(items)
  69. if err != nil {
  70. return mcp.NewToolResultError(err.Error())
  71. }
  72. arr, _ := tree.([]any)
  73. if total < 0 {
  74. total = len(arr)
  75. }
  76. if len(arr) == 0 {
  77. return Empty("")
  78. }
  79. r := buildList(arr, total, limit, hint)
  80. b, _ := json.Marshal(r)
  81. return mcp.NewToolResultText(string(b))
  82. }
  83. func buildList(arr []any, total, limit int, hint string) ListResult {
  84. n := len(arr)
  85. if limit > 0 && n > limit {
  86. n = limit
  87. }
  88. for {
  89. r := ListResult{Total: total, Returned: n, Truncated: n < total, Items: arr[:n]}
  90. if r.Truncated {
  91. r.Hint = hint
  92. if r.Hint == "" {
  93. r.Hint = "结果已截断,请缩小查询范围或分页查询"
  94. }
  95. }
  96. b, _ := json.Marshal(r)
  97. if utf8.RuneCount(b) <= MaxResultChars || n <= 1 {
  98. return r
  99. }
  100. n = n * 3 / 4
  101. }
  102. }
  103. // Table 行多、字段固定的数据:首行列名的 TSV,比 JSON 省字符。单元格按列名脱敏。
  104. func Table(cols []string, rows [][]string, limit int, hint string) *mcp.CallToolResult {
  105. if len(rows) == 0 {
  106. return Empty("")
  107. }
  108. total := len(rows)
  109. n := total
  110. if limit > 0 && n > limit {
  111. n = limit
  112. }
  113. var buf strings.Builder
  114. header := fmt.Sprintf("# total=%d", total)
  115. colLine := strings.Join(cols, "\t")
  116. buf.WriteString(colLine)
  117. buf.WriteByte('\n')
  118. chars := utf8.RuneCountInString(colLine) + 1
  119. written := 0
  120. for _, row := range rows[:n] {
  121. line := tsvLine(cols, row)
  122. lc := utf8.RuneCountInString(line) + 1
  123. // 预留 200 字符给头部说明
  124. if chars+lc+200 > MaxResultChars {
  125. break
  126. }
  127. buf.WriteString(line)
  128. buf.WriteByte('\n')
  129. chars += lc
  130. written++
  131. }
  132. header += fmt.Sprintf(" returned=%d truncated=%t", written, written < total)
  133. if written < total {
  134. if hint == "" {
  135. hint = "结果已截断,请缩小查询范围"
  136. }
  137. header += "\n# " + hint
  138. }
  139. return mcp.NewToolResultText(header + "\n" + buf.String())
  140. }
  141. func tsvLine(cols []string, row []string) string {
  142. cells := make([]string, len(row))
  143. for i, c := range row {
  144. if i < len(cols) {
  145. c = pii.MaskField(cols[i], c)
  146. }
  147. cells[i] = strings.NewReplacer("\t", " ", "\n", " ", "\r", " ").Replace(c)
  148. }
  149. return strings.Join(cells, "\t")
  150. }
  151. // Export 导出:CSV 以 EmbeddedResource(text/csv)返回,并附文本摘要。
  152. // AgentServer 网关会把资源内容保存到会话工作区(agent-mcp.md 5.2)。
  153. func Export(uri string, cols []string, rows [][]string, summary string) *mcp.CallToolResult {
  154. if len(rows) == 0 {
  155. return Empty("没有可导出的数据。")
  156. }
  157. var buf bytes.Buffer
  158. w := csv.NewWriter(&buf)
  159. _ = w.Write(cols)
  160. for _, row := range rows {
  161. masked := make([]string, len(row))
  162. for i, c := range row {
  163. if i < len(cols) {
  164. c = pii.MaskField(cols[i], c)
  165. }
  166. masked[i] = c
  167. }
  168. _ = w.Write(masked)
  169. if buf.Len() > MaxExportBytes {
  170. return Failf("导出数据超过 %d MB,请缩小时间范围后分段导出", MaxExportBytes>>20)
  171. }
  172. }
  173. w.Flush()
  174. preview := rows
  175. if len(preview) > 5 {
  176. preview = preview[:5]
  177. }
  178. var head strings.Builder
  179. fmt.Fprintf(&head, "已导出 %d 行,%d 列,%.1f KB。\n资源:%s\n列:%s\n", len(rows), len(cols), float64(buf.Len())/1024, uri, strings.Join(cols, ", "))
  180. if summary != "" {
  181. head.WriteString(summary + "\n")
  182. }
  183. head.WriteString("前 5 行:\n")
  184. head.WriteString(strings.Join(cols, "\t") + "\n")
  185. for _, r := range preview {
  186. head.WriteString(tsvLine(cols, r) + "\n")
  187. }
  188. head.WriteString("完整数据在资源文件中,请用 Python(pandas)读取分析,不要把全文读入上下文。")
  189. return &mcp.CallToolResult{
  190. Content: []mcp.Content{
  191. mcp.NewTextContent(head.String()),
  192. mcp.NewEmbeddedResource(mcp.TextResourceContents{URI: uri, MIMEType: "text/csv", Text: buf.String()}),
  193. },
  194. }
  195. }
  196. // ---- 内部 ----
  197. // sanitize 序列化 → 脱敏 → 解析为通用树 → 去掉空值。
  198. func sanitize(v any) (any, error) {
  199. if v == nil || (reflect.ValueOf(v).Kind() == reflect.Ptr && reflect.ValueOf(v).IsNil()) {
  200. return nil, nil
  201. }
  202. b, err := pii.Marshal(v)
  203. if err != nil {
  204. return nil, err
  205. }
  206. dec := json.NewDecoder(bytes.NewReader(b))
  207. dec.UseNumber()
  208. var tree any
  209. if err := dec.Decode(&tree); err != nil {
  210. return nil, fmt.Errorf("结果解析失败: %w", err)
  211. }
  212. return Prune(tree), nil
  213. }
  214. // Prune 去掉 null、空字符串、空数组、空对象(递归)。数组中的元素保留位置,只清理其内部。
  215. func Prune(v any) any {
  216. switch t := v.(type) {
  217. case map[string]any:
  218. for k, val := range t {
  219. p := Prune(val)
  220. if isEmpty(p) {
  221. delete(t, k)
  222. } else {
  223. t[k] = p
  224. }
  225. }
  226. return t
  227. case []any:
  228. for i := range t {
  229. t[i] = Prune(t[i])
  230. }
  231. return t
  232. default:
  233. return v
  234. }
  235. }
  236. func isEmpty(v any) bool {
  237. switch t := v.(type) {
  238. case nil:
  239. return true
  240. case string:
  241. return t == ""
  242. case map[string]any:
  243. return len(t) == 0
  244. case []any:
  245. return len(t) == 0
  246. }
  247. return false
  248. }
  249. func truncateRunes(s string, n int) string {
  250. if utf8.RuneCountInString(s) <= n {
  251. return s
  252. }
  253. r := []rune(s)
  254. return string(r[:n])
  255. }