output_presenter.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import json
  2. from constants import DEVICE_INVALID_MESSAGE
  3. # Builds AI-safe MCP results for the explicitly supported tool set.
  4. class OutputPresenter:
  5. TABLE_TOOLS = frozenset((
  6. 'query_order_exact',
  7. 'query_track',
  8. ))
  9. OPTION_TOOLS = frozenset((
  10. 'list_order_filter_options',
  11. 'list_pending_outbound_export_filter_options',
  12. ))
  13. EXPORT_TOOLS = frozenset((
  14. 'export_pending_outbound_orders',
  15. 'export_out_of_province_port_data',
  16. ))
  17. SAFE_TOOLS = TABLE_TOOLS | OPTION_TOOLS | EXPORT_TOOLS
  18. FIELD_LABELS = {
  19. 'query_order_exact': {
  20. 'order_number': '订单号',
  21. 'order_numbers': '订单号',
  22. 'reference_number': '客户参考号',
  23. 'reference_numbers': '客户参考号',
  24. 'tracking_number': '快递单号',
  25. 'tracking_numbers': '快递单号',
  26. 'outbound_number': '排舱单号',
  27. 'outbound_numbers': '排舱单号',
  28. 'container_code': '柜号',
  29. 'container_codes': '柜号',
  30. 'so_number': 'SO号',
  31. 'so_numbers': 'SO号',
  32. 'shipment_id': 'Shipment ID',
  33. 'receiver_country': '收货国家',
  34. 'product_ids': '物流产品',
  35. 'customer_ids': '客户',
  36. 'sales_id': '销售人员',
  37. 'warehouse_ids': '仓库',
  38. 'department_id': '事业部',
  39. 'inbound_date_start': '入库开始日期',
  40. 'inbound_date_end': '入库结束日期',
  41. 'outbound_date_start': '出库开始日期',
  42. 'outbound_date_end': '出库结束日期',
  43. 'page': '页码',
  44. 'limit': '每页数量',
  45. },
  46. 'query_track': {
  47. 'order_id': '订单ID',
  48. 'order_number': '订单号',
  49. 'tracking_number': '快递单号',
  50. 'page': '页码',
  51. 'limit': '每页数量',
  52. },
  53. 'list_order_filter_options': {
  54. 'filter_type': '筛选项类型',
  55. 'keyword': '关键词',
  56. 'page': '页码',
  57. 'limit': '每页数量',
  58. },
  59. 'list_pending_outbound_export_filter_options': {
  60. 'filter_type': '筛选项类型',
  61. 'product_type_id': '产品分类',
  62. 'keyword': '关键词',
  63. 'page': '页码',
  64. 'limit': '每页数量',
  65. },
  66. 'export_pending_outbound_orders': {
  67. 'number': '单号',
  68. 'product_type_id': '产品分类',
  69. 'product_id': '物流产品',
  70. 'order_warehouse_id': '集货仓库',
  71. 'receiver_country': '目的国',
  72. 'is_remove': '是否装柜剔除',
  73. 'address': '派送地址',
  74. 'inbound_date': '入库时间',
  75. 'status': '订单状态',
  76. 'merge_declare_number': '合并报关单号',
  77. 'importer_id': '进口商',
  78. 'packing_type': '货物类型',
  79. 'is_battery': '带电属性',
  80. 'is_magnetic': '带磁属性',
  81. 'is_wood': '带木属性',
  82. 'is_other': '其他商品属性',
  83. 'is_fda': 'FDA产品属性',
  84. 'is_toy': '玩具属性',
  85. 'is_ultra_limit': '超长超重属性',
  86. 'is_sensitive': '敏感货属性',
  87. 'is_food': '食品属性',
  88. 'no_property': '无属性',
  89. },
  90. 'export_out_of_province_port_data': {
  91. 'outbound_numbers': '排舱单号',
  92. 'file_type': '资料类型',
  93. },
  94. }
  95. ERROR_MESSAGES = {
  96. 'MCP_1101': '设备配置已失效,请重新生成配置',
  97. 'MCP_1102': '设备会话已过期,请重新连接',
  98. 'MCP_1201': '工具当前不可用',
  99. 'MCP_1202': '工具当前不可用',
  100. 'MCP_1301': '没有权限使用此工具',
  101. 'MCP_9001': '工具调用失败,请稍后重试',
  102. }
  103. def handles(self, tool_name):
  104. return isinstance(tool_name, str) and tool_name in self.SAFE_TOOLS
  105. # Convert a backend envelope into text and structured display data.
  106. def present(self, tool_name, tool_result):
  107. if not self.handles(tool_name) or not isinstance(tool_result, dict):
  108. return self._format_error()
  109. code = str(tool_result.get('code') or '').strip()
  110. meta = self._build_meta(tool_result.get('meta'))
  111. if code not in ('MCP_0000', '0'):
  112. return self._business_error(
  113. tool_name,
  114. code or 'MCP_9001',
  115. tool_result.get('msg'),
  116. meta,
  117. )
  118. data = tool_result.get('data')
  119. if not isinstance(data, dict):
  120. return self._format_error(meta)
  121. if tool_name in self.TABLE_TOOLS:
  122. return self._present_table(data, tool_result.get('meta'), meta)
  123. if tool_name in self.OPTION_TOOLS:
  124. return self._present_options(data, tool_result.get('meta'), meta)
  125. return self._present_export(data, meta)
  126. # Map local tool exceptions without exposing internal error details.
  127. def present_exception(self, tool_name, exception):
  128. if not self.handles(tool_name):
  129. return self._format_error()
  130. message = str(exception or '').strip()
  131. if isinstance(exception, ValueError):
  132. label = self._find_field_label(tool_name, message)
  133. return self._error_result(
  134. 'MCP_1401',
  135. self._parameter_message(label),
  136. False,
  137. )
  138. if message == DEVICE_INVALID_MESSAGE:
  139. return self._error_result('MCP_1101', DEVICE_INVALID_MESSAGE, False)
  140. if message.lower().startswith('tool disabled'):
  141. return self._error_result('MCP_1202', '工具当前不可用', False)
  142. return self._error_result(
  143. 'MCP_9001',
  144. '工具调用失败,请稍后重试',
  145. True,
  146. )
  147. def _present_table(self, data, raw_meta, meta):
  148. columns = data.get('columns')
  149. records = data.get('records')
  150. if not isinstance(columns, list) or not columns or not isinstance(records, list):
  151. return self._format_error(meta)
  152. headers = []
  153. keys = []
  154. for column in columns:
  155. if not isinstance(column, dict):
  156. return self._format_error(meta)
  157. key = column.get('key')
  158. label = column.get('name')
  159. if not isinstance(key, str) or not key.strip():
  160. return self._format_error(meta)
  161. if not isinstance(label, str) or not label.strip():
  162. return self._format_error(meta)
  163. keys.append(key.strip())
  164. header = {'label': label.strip()}
  165. description = column.get('description')
  166. if isinstance(description, str) and description.strip():
  167. header['description'] = description.strip()
  168. headers.append(header)
  169. rows = []
  170. for record in records:
  171. if not isinstance(record, dict):
  172. return self._format_error(meta)
  173. rows.append([
  174. '' if record.get(key) is None else record.get(key, '')
  175. for key in keys
  176. ])
  177. content = {
  178. 'summary': self._safe_text(data.get('summary')),
  179. 'headers': headers,
  180. 'rows': rows,
  181. }
  182. tips = self._safe_tips(data.get('tips'))
  183. if tips:
  184. content['tips'] = tips
  185. pagination = self._build_pagination(raw_meta)
  186. if pagination:
  187. content['pagination'] = pagination
  188. return self._success_result(content, self._render_table(content), meta)
  189. def _present_options(self, data, raw_meta, meta):
  190. records = data.get('records')
  191. if not isinstance(records, list):
  192. return self._format_error(meta)
  193. rows = []
  194. for record in records:
  195. if not isinstance(record, dict):
  196. return self._format_error(meta)
  197. rows.append([
  198. record.get('value', ''),
  199. self._safe_text(record.get('label')),
  200. self._safe_text(record.get('code')),
  201. ])
  202. content = {
  203. 'headers': [
  204. {'label': '可传值'},
  205. {'label': '显示名称'},
  206. {'label': '业务编码'},
  207. ],
  208. 'rows': rows,
  209. }
  210. pagination = self._build_pagination(raw_meta)
  211. if pagination:
  212. content['pagination'] = pagination
  213. return self._success_result(content, self._render_table(content), meta)
  214. def _present_export(self, data, meta):
  215. url = data.get('file_url')
  216. if not isinstance(url, str) or not url.strip():
  217. return self._format_error(meta)
  218. content = {
  219. 'message': '文件已生成',
  220. 'files': [{'label': '导出文件', 'url': url.strip()}],
  221. }
  222. text = '文件已生成\n- 导出文件: {0}'.format(url.strip())
  223. return self._success_result(content, text, meta)
  224. def _business_error(self, tool_name, code, raw_message, meta):
  225. if code == 'MCP_1401':
  226. label = self._find_field_label(tool_name, raw_message)
  227. message = self._parameter_message(label)
  228. retryable = False
  229. else:
  230. message = self.ERROR_MESSAGES.get(
  231. code,
  232. '工具调用失败,请稍后重试',
  233. )
  234. retryable = code not in ('MCP_1101', 'MCP_1102', 'MCP_1201', 'MCP_1202', 'MCP_1301')
  235. return self._error_result(code, message, retryable, meta)
  236. def _find_field_label(self, tool_name, raw_message):
  237. message = str(raw_message or '')
  238. fields = self.FIELD_LABELS.get(tool_name, {})
  239. for field in sorted(fields, key=len, reverse=True):
  240. if field in message:
  241. return fields[field]
  242. return ''
  243. @staticmethod
  244. def _parameter_message(label):
  245. return '{0}参数不正确'.format(label) if label else '工具参数不正确,请检查后重试'
  246. @staticmethod
  247. def _build_meta(raw_meta):
  248. if not isinstance(raw_meta, dict):
  249. return {}
  250. request_id = raw_meta.get('request_id')
  251. if not isinstance(request_id, str) or not request_id.strip():
  252. return {}
  253. return {'request_id': request_id.strip()}
  254. @staticmethod
  255. def _build_pagination(raw_meta):
  256. if not isinstance(raw_meta, dict):
  257. return {}
  258. return {
  259. key: raw_meta[key]
  260. for key in ('page', 'limit', 'has_more', 'total')
  261. if key in raw_meta
  262. }
  263. @staticmethod
  264. def _safe_text(value):
  265. if value is None:
  266. return ''
  267. return str(value).strip()
  268. @classmethod
  269. def _safe_tips(cls, value):
  270. if not isinstance(value, list):
  271. return []
  272. return [cls._safe_text(item) for item in value if cls._safe_text(item)]
  273. @classmethod
  274. def _render_table(cls, content):
  275. headers = content.get('headers') or []
  276. rows = content.get('rows') or []
  277. lines = []
  278. summary = cls._safe_text(content.get('summary'))
  279. if summary:
  280. lines.append(summary)
  281. lines.append('表头共 {0} 列:'.format(len(headers)))
  282. for index, header in enumerate(headers, start=1):
  283. lines.append('{0}. {1}'.format(index, header['label']))
  284. for index, row in enumerate(rows, start=1):
  285. lines.append('记录 {0}:'.format(index))
  286. for header, value in zip(headers, row):
  287. if isinstance(value, (dict, list)):
  288. value = json.dumps(value, ensure_ascii=False)
  289. lines.append('- {0}: {1}'.format(header['label'], value))
  290. tips = content.get('tips') or []
  291. if tips:
  292. lines.append('提示:{0}'.format(';'.join(tips)))
  293. return '\n'.join(lines)
  294. @staticmethod
  295. def _success_result(content, text, meta):
  296. return {
  297. 'structured_content': content,
  298. 'text': text,
  299. 'is_error': False,
  300. 'meta': meta,
  301. }
  302. @staticmethod
  303. def _error_result(code, message, retryable, meta=None):
  304. return {
  305. 'structured_content': {
  306. 'code': code,
  307. 'message': message,
  308. 'retryable': retryable,
  309. },
  310. 'text': '{0}: {1}'.format(code, message),
  311. 'is_error': True,
  312. 'meta': meta or {},
  313. }
  314. @classmethod
  315. def _format_error(cls, meta=None):
  316. return cls._error_result(
  317. 'MCP_9001',
  318. '工具返回格式异常',
  319. True,
  320. meta,
  321. )