result.go 9.3 KB

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