output_presenter.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  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. 'query_customs_declaration_files',
  9. ))
  10. OPTION_TOOLS = frozenset((
  11. 'list_order_filter_options',
  12. 'list_pending_outbound_export_filter_options',
  13. ))
  14. EXPORT_TOOLS = frozenset((
  15. 'export_pending_outbound_orders',
  16. 'export_out_of_province_port_data',
  17. ))
  18. SAFE_TOOLS = TABLE_TOOLS | OPTION_TOOLS | EXPORT_TOOLS
  19. TABLE_COLUMNS = {
  20. 'query_order_exact': {
  21. 'order_number': ('订单号',),
  22. 'reference_number': ('客户参考号',),
  23. 'status_txt_name': ('状态',),
  24. 'check_status_txt_name': ('是否已查验',),
  25. 'customer_name': ('客户名称',),
  26. 'customer_account_type_name': ('客户属性',),
  27. 'inbound_date': ('入库时间',),
  28. 'wo_num': ('未完成工单',),
  29. 'product_name': ('物流产品',),
  30. 'inbound_pieces': ('件数',),
  31. 'inbound_volume': ('体积(CBM)',),
  32. 'inbound_weight': ('重量(KG)',),
  33. 'pro_cn_name': ('品名',),
  34. 'export_declaration_type': ('报关方式',),
  35. 'merge_declare_number': ('合并报关单号',),
  36. 'delivery_address': ('派送地址',),
  37. 'container_code': ('柜号',),
  38. 'out_status_txt': ('排舱单状态',),
  39. 'hinge_of_destination': ('目的港',),
  40. 'etd': ('ETD',),
  41. 'atd': ('ATD',),
  42. 'eta': ('ETA',),
  43. 'ata': ('ATA',),
  44. 'release_time': ('清关放行时间',),
  45. 'oversea_inbound_date': ('海外入库时间',),
  46. 'appt_time': ('APPT时间',),
  47. 'est_loading_time': ('预计装柜时间',),
  48. 'pickup_time': ('海外提柜时间',),
  49. 'delivery_way_title': ('派送方式',),
  50. 'tracking_number': ('快递单号', '承运商跟踪号码'),
  51. 'shipment_id': ('Shipment ID',),
  52. 'goods_attribute': ('商品属性',),
  53. 'sku': ('SKU',),
  54. 'sales_user': ('商务经理',),
  55. 'service_user': ('客户经理',),
  56. 'department_name': ('事业部',),
  57. 'remark': ('订单备注',),
  58. 'importer_name': ('进口商',),
  59. 'warehouse_name': ('交货仓库',),
  60. 'paid_status_name': ('付款状态',),
  61. },
  62. 'query_track': {
  63. 'status': ('轨迹节点', '轨迹状态,如:开始集港、离港放行、清关、配送等'),
  64. 'location': ('轨迹地点', '发生地点'),
  65. 'time': ('时间', '轨迹发生时间(已转换为用户时区)'),
  66. 'content': ('轨迹内容', '详细描述'),
  67. 'tracking_number': ('跟踪号', '快递单号'),
  68. 'shipment_id': ('Shipment ID', '包裹ID'),
  69. },
  70. 'query_customs_declaration_files': {
  71. 'outbound_number': ('排舱单号',),
  72. 'order_number': ('订单号',),
  73. 'file_name': ('文件名',),
  74. 'file_type': ('文件类型',),
  75. 'file_url': ('文件链接',),
  76. },
  77. }
  78. FIELD_LABELS = {
  79. 'query_order_exact': {
  80. 'order_number': '订单号',
  81. 'order_numbers': '订单号',
  82. 'reference_number': '客户参考号',
  83. 'reference_numbers': '客户参考号',
  84. 'tracking_number': '快递单号',
  85. 'tracking_numbers': '快递单号',
  86. 'outbound_number': '排舱单号',
  87. 'outbound_numbers': '排舱单号',
  88. 'container_code': '柜号',
  89. 'container_codes': '柜号',
  90. 'so_number': 'SO号',
  91. 'so_numbers': 'SO号',
  92. 'shipment_id': 'Shipment ID',
  93. 'receiver_country': '收货国家',
  94. 'product_ids': '物流产品',
  95. 'customer_ids': '客户',
  96. 'sales_id': '销售人员',
  97. 'warehouse_ids': '仓库',
  98. 'department_id': '事业部',
  99. 'inbound_date_start': '入库开始日期',
  100. 'inbound_date_end': '入库结束日期',
  101. 'outbound_date_start': '出库开始日期',
  102. 'outbound_date_end': '出库结束日期',
  103. 'page': '页码',
  104. 'limit': '每页数量',
  105. },
  106. 'query_track': {
  107. 'order_id': '订单ID',
  108. 'order_number': '订单号',
  109. 'tracking_number': '快递单号',
  110. 'page': '页码',
  111. 'limit': '每页数量',
  112. },
  113. 'query_customs_declaration_files': {
  114. 'outbound_numbers': '排舱单号',
  115. 'order_numbers': '订单号',
  116. 'page': '页码',
  117. 'limit': '每页数量',
  118. },
  119. 'list_order_filter_options': {
  120. 'filter_type': '筛选项类型',
  121. 'keyword': '关键词',
  122. 'page': '页码',
  123. 'limit': '每页数量',
  124. },
  125. 'list_pending_outbound_export_filter_options': {
  126. 'filter_type': '筛选项类型',
  127. 'product_type_id': '产品分类',
  128. 'keyword': '关键词',
  129. 'page': '页码',
  130. 'limit': '每页数量',
  131. },
  132. 'export_pending_outbound_orders': {
  133. 'number': '单号',
  134. 'product_type_id': '产品分类',
  135. 'product_id': '物流产品',
  136. 'order_warehouse_id': '集货仓库',
  137. 'receiver_country': '目的国',
  138. 'is_remove': '是否装柜剔除',
  139. 'address': '派送地址',
  140. 'inbound_date': '入库时间',
  141. 'status': '订单状态',
  142. 'merge_declare_number': '合并报关单号',
  143. 'importer_id': '进口商',
  144. 'packing_type': '货物类型',
  145. 'is_battery': '带电属性',
  146. 'is_magnetic': '带磁属性',
  147. 'is_wood': '带木属性',
  148. 'is_other': '其他商品属性',
  149. 'is_fda': 'FDA产品属性',
  150. 'is_toy': '玩具属性',
  151. 'is_ultra_limit': '超长超重属性',
  152. 'is_sensitive': '敏感货属性',
  153. 'is_food': '食品属性',
  154. 'no_property': '无属性',
  155. },
  156. 'export_out_of_province_port_data': {
  157. 'outbound_numbers': '排舱单号',
  158. 'container_codes': '柜号',
  159. 'bl_numbers': '提单号',
  160. 'so_numbers': 'SO号',
  161. 'file_type': '资料类型',
  162. },
  163. }
  164. ERROR_MESSAGES = {
  165. 'MCP_1101': '设备配置已失效,请重新生成配置',
  166. 'MCP_1102': '设备会话已过期,请重新连接',
  167. 'MCP_1201': '工具当前不可用',
  168. 'MCP_1202': '工具当前不可用',
  169. 'MCP_1301': '没有权限使用此工具',
  170. 'MCP_9001': '工具调用失败,请稍后重试',
  171. }
  172. def handles(self, tool_name):
  173. return isinstance(tool_name, str) and tool_name in self.SAFE_TOOLS
  174. # Convert a backend envelope into text and structured display data.
  175. def present(self, tool_name, tool_result):
  176. if not self.handles(tool_name) or not isinstance(tool_result, dict):
  177. return self._format_error()
  178. code = str(tool_result.get('code') or '').strip()
  179. meta = self._build_meta(tool_result.get('meta'))
  180. if code not in ('MCP_0000', '0'):
  181. return self._business_error(
  182. tool_name,
  183. code or 'MCP_9001',
  184. tool_result.get('msg'),
  185. meta,
  186. )
  187. data = tool_result.get('data')
  188. if not isinstance(data, dict):
  189. return self._format_error(meta)
  190. if tool_name in self.TABLE_TOOLS:
  191. return self._present_table(
  192. tool_name,
  193. data,
  194. tool_result.get('meta'),
  195. meta,
  196. )
  197. if tool_name in self.OPTION_TOOLS:
  198. return self._present_options(data, tool_result.get('meta'), meta)
  199. return self._present_export(data, meta)
  200. # Map local tool exceptions without exposing internal error details.
  201. def present_exception(self, tool_name, exception):
  202. if not self.handles(tool_name):
  203. return self._format_error()
  204. message = str(exception or '').strip()
  205. if isinstance(exception, ValueError):
  206. label = self._find_field_label(tool_name, message)
  207. return self._error_result(
  208. 'MCP_1401',
  209. self._parameter_message(label),
  210. False,
  211. )
  212. if message == DEVICE_INVALID_MESSAGE:
  213. return self._error_result('MCP_1101', DEVICE_INVALID_MESSAGE, False)
  214. if message.lower().startswith('tool disabled'):
  215. return self._error_result('MCP_1202', '工具当前不可用', False)
  216. return self._error_result(
  217. 'MCP_9001',
  218. '工具调用失败,请稍后重试',
  219. True,
  220. )
  221. def _present_table(self, tool_name, data, raw_meta, meta):
  222. columns = data.get('columns')
  223. records = data.get('records')
  224. if not isinstance(columns, list) or not columns or not isinstance(records, list):
  225. return self._format_error(meta)
  226. allowed_columns = self.TABLE_COLUMNS.get(tool_name)
  227. if not isinstance(allowed_columns, dict):
  228. return self._format_error(meta)
  229. headers = []
  230. keys = []
  231. for column in columns:
  232. if not isinstance(column, dict):
  233. return self._format_error(meta)
  234. key = column.get('key')
  235. label = column.get('name')
  236. if not isinstance(key, str) or not key.strip():
  237. return self._format_error(meta)
  238. if not isinstance(label, str) or not label.strip():
  239. return self._format_error(meta)
  240. key = key.strip()
  241. definition = allowed_columns.get(key)
  242. if not isinstance(definition, tuple) or not definition:
  243. return self._format_error(meta)
  244. keys.append(key)
  245. header = {'label': definition[0]}
  246. if len(definition) > 1:
  247. header['description'] = definition[1]
  248. headers.append(header)
  249. rows = []
  250. for record in records:
  251. if not isinstance(record, dict):
  252. return self._format_error(meta)
  253. rows.append([
  254. '' if record.get(key) is None else record.get(key, '')
  255. for key in keys
  256. ])
  257. content = {
  258. 'summary': self._safe_text(data.get('summary')),
  259. 'headers': headers,
  260. 'rows': rows,
  261. }
  262. tips = self._safe_tips(data.get('tips'))
  263. if tips:
  264. content['tips'] = tips
  265. pagination = self._build_pagination(raw_meta)
  266. if pagination:
  267. content['pagination'] = pagination
  268. return self._success_result(content, self._render_table(content), meta)
  269. def _present_options(self, data, raw_meta, meta):
  270. records = data.get('records')
  271. if not isinstance(records, list):
  272. return self._format_error(meta)
  273. rows = []
  274. for record in records:
  275. if not isinstance(record, dict):
  276. return self._format_error(meta)
  277. rows.append([
  278. record.get('value', ''),
  279. self._safe_text(record.get('label')),
  280. self._safe_text(record.get('code')),
  281. ])
  282. content = {
  283. 'headers': [
  284. {'label': '可传值'},
  285. {'label': '显示名称'},
  286. {'label': '业务编码'},
  287. ],
  288. 'rows': rows,
  289. }
  290. pagination = self._build_pagination(raw_meta)
  291. if pagination:
  292. content['pagination'] = pagination
  293. return self._success_result(content, self._render_table(content), meta)
  294. def _present_export(self, data, meta):
  295. url = data.get('file_url')
  296. if not isinstance(url, str) or not url.strip():
  297. return self._format_error(meta)
  298. content = {
  299. 'message': '文件已生成',
  300. 'files': [{'label': '导出文件', 'url': url.strip()}],
  301. }
  302. text = '文件已生成\n- 导出文件: {0}'.format(url.strip())
  303. return self._success_result(content, text, meta)
  304. def _business_error(self, tool_name, code, raw_message, meta):
  305. if code == 'MCP_1401':
  306. label = self._find_field_label(tool_name, raw_message)
  307. message = self._parameter_message(label)
  308. retryable = False
  309. else:
  310. message = self.ERROR_MESSAGES.get(
  311. code,
  312. '工具调用失败,请稍后重试',
  313. )
  314. retryable = code not in ('MCP_1101', 'MCP_1102', 'MCP_1201', 'MCP_1202', 'MCP_1301')
  315. return self._error_result(code, message, retryable, meta)
  316. def _find_field_label(self, tool_name, raw_message):
  317. message = str(raw_message or '')
  318. fields = self.FIELD_LABELS.get(tool_name, {})
  319. for field in sorted(fields, key=len, reverse=True):
  320. if field in message:
  321. return fields[field]
  322. return ''
  323. @staticmethod
  324. def _parameter_message(label):
  325. return '{0}参数不正确'.format(label) if label else '工具参数不正确,请检查后重试'
  326. @staticmethod
  327. def _build_meta(raw_meta):
  328. if not isinstance(raw_meta, dict):
  329. return {}
  330. request_id = raw_meta.get('request_id')
  331. if not isinstance(request_id, str) or not request_id.strip():
  332. return {}
  333. return {'request_id': request_id.strip()}
  334. @staticmethod
  335. def _build_pagination(raw_meta):
  336. if not isinstance(raw_meta, dict):
  337. return {}
  338. return {
  339. key: raw_meta[key]
  340. for key in ('page', 'limit', 'has_more', 'total')
  341. if key in raw_meta
  342. }
  343. @staticmethod
  344. def _safe_text(value):
  345. if value is None:
  346. return ''
  347. return str(value).strip()
  348. @classmethod
  349. def _safe_tips(cls, value):
  350. if not isinstance(value, list):
  351. return []
  352. return [cls._safe_text(item) for item in value if cls._safe_text(item)]
  353. @classmethod
  354. def _render_table(cls, content):
  355. headers = content.get('headers') or []
  356. rows = content.get('rows') or []
  357. lines = []
  358. summary = cls._safe_text(content.get('summary'))
  359. if summary:
  360. lines.append(summary)
  361. lines.append('表头共 {0} 列:'.format(len(headers)))
  362. for index, header in enumerate(headers, start=1):
  363. lines.append('{0}. {1}'.format(index, header['label']))
  364. for index, row in enumerate(rows, start=1):
  365. lines.append('记录 {0}:'.format(index))
  366. for header, value in zip(headers, row):
  367. if isinstance(value, (dict, list)):
  368. value = json.dumps(value, ensure_ascii=False)
  369. lines.append('- {0}: {1}'.format(header['label'], value))
  370. tips = content.get('tips') or []
  371. if tips:
  372. lines.append('提示:{0}'.format(';'.join(tips)))
  373. return '\n'.join(lines)
  374. @staticmethod
  375. def _success_result(content, text, meta):
  376. return {
  377. 'structured_content': content,
  378. 'text': text,
  379. 'is_error': False,
  380. 'meta': meta,
  381. }
  382. @staticmethod
  383. def _error_result(code, message, retryable, meta=None):
  384. return {
  385. 'structured_content': {
  386. 'code': code,
  387. 'message': message,
  388. 'retryable': retryable,
  389. },
  390. 'text': '{0}: {1}'.format(code, message),
  391. 'is_error': True,
  392. 'meta': meta or {},
  393. }
  394. @classmethod
  395. def _format_error(cls, meta=None):
  396. return cls._error_result(
  397. 'MCP_9001',
  398. '工具返回格式异常',
  399. True,
  400. meta,
  401. )