diff --git a/backend/api/server.py b/backend/api/server.py index 616fc48..c371858 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -2634,10 +2634,10 @@ def _create_feishu_online_spreadsheet( create_params: dict[str, Any] = {'title': title} if folder_token and folder_token.strip(): create_params['folder_token'] = folder_token.strip() - rendered, metadata = runtime.call_tool( + rendered, metadata = _call_feishu_mcp_tool_checked( + runtime, 'sheet_ops', arguments={'action': 'create', 'params': create_params}, - server_name=FEISHU_MCP_SERVER_NAME, max_chars=API_TOOL_CONTENT_MAX_CHARS, timeout_seconds=60.0, ) @@ -2645,13 +2645,14 @@ def _create_feishu_online_spreadsheet( if not spreadsheet_token: raise DataAgentInputError('飞书表格已创建,但没有解析到 spreadsheet token') - sheet_refs = _discover_feishu_sheet_refs(runtime, spreadsheet_token) for index, sheet_data in enumerate(sheets): rows = sheet_data['rows'] if not rows: continue if index == 0: - sheet_ref = sheet_refs[0] if sheet_refs else 'Sheet1' + # 新建飞书表格默认会带一个名为 Sheet1 的工作表。 + # 不从 spreadsheet meta 里取顶层 title,避免把文件名误当成工作表名。 + sheet_ref = 'Sheet1' else: sheet_ref = _create_feishu_worksheet( runtime, @@ -2663,6 +2664,44 @@ def _create_feishu_online_spreadsheet( return rendered, metadata +def _call_feishu_mcp_tool_checked( + runtime: MCPRuntime, + tool_name: str, + *, + arguments: dict[str, Any], + max_chars: int, + timeout_seconds: float, +) -> tuple[str, dict[str, Any]]: + rendered, metadata = runtime.call_tool( + tool_name, + arguments=arguments, + server_name=FEISHU_MCP_SERVER_NAME, + max_chars=max_chars, + timeout_seconds=timeout_seconds, + ) + error_message = _extract_feishu_mcp_error(rendered) + if metadata.get('is_error') or error_message: + raise DataAgentInputError(error_message or f'飞书 MCP 工具 {tool_name} 调用失败') + return rendered, metadata + + +def _extract_feishu_mcp_error(text: str) -> str | None: + payload = _parse_first_json_object(text) + if not isinstance(payload, dict): + return None + error_value = payload.get('error') + if not error_value: + return None + suggestion = payload.get('suggestion') + code = payload.get('code') + parts = [str(error_value)] + if code is not None: + parts.append(f'code={code}') + if suggestion: + parts.append(str(suggestion)) + return ';'.join(parts) + + def _load_file_as_feishu_sheets(file_path: Path) -> list[dict[str, Any]]: suffix = file_path.suffix.lower() if suffix == '.csv': @@ -2762,10 +2801,10 @@ def _extract_feishu_spreadsheet_token(text: str) -> str | None: def _discover_feishu_sheet_refs(runtime: MCPRuntime, spreadsheet_token: str) -> list[str]: try: - rendered, _metadata = runtime.call_tool( + rendered, _metadata = _call_feishu_mcp_tool_checked( + runtime, 'sheet_ops', arguments={'action': 'meta', 'params': {'url_or_token': spreadsheet_token}}, - server_name=FEISHU_MCP_SERVER_NAME, max_chars=API_TOOL_CONTENT_MAX_CHARS, timeout_seconds=60.0, ) @@ -2785,7 +2824,11 @@ def _extract_feishu_sheet_refs(text: str) -> list[str]: raw_title = value.get('title') or value.get('name') if isinstance(raw_id, str) and raw_id.strip(): refs.append(raw_id.strip()) - elif isinstance(raw_title, str) and raw_title.strip(): + elif ( + isinstance(raw_title, str) + and raw_title.strip() + and ('index' in value or 'row_count' in value or 'col_count' in value) + ): refs.append(raw_title.strip()) for child in value.values(): visit(child) @@ -2816,7 +2859,8 @@ def _create_feishu_worksheet( index: int, ) -> str: sheet_title = _clean_feishu_sheet_title(title) or f'Sheet{index + 1}' - rendered, _metadata = runtime.call_tool( + rendered, _metadata = _call_feishu_mcp_tool_checked( + runtime, 'sheet_ops', arguments={ 'action': 'add_sheet', @@ -2826,7 +2870,6 @@ def _create_feishu_worksheet( 'index': index, }, }, - server_name=FEISHU_MCP_SERVER_NAME, max_chars=API_TOOL_CONTENT_MAX_CHARS, timeout_seconds=60.0, ) @@ -2848,7 +2891,8 @@ def _write_feishu_sheet_rows( chunk = rows[start : start + FEISHU_SPREADSHEET_WRITE_BATCH_ROWS] start_row = start + 1 end_row = start + len(chunk) - runtime.call_tool( + _call_feishu_mcp_tool_checked( + runtime, 'sheet_ops', arguments={ 'action': 'write', @@ -2858,7 +2902,6 @@ def _write_feishu_sheet_rows( 'values': json.dumps(chunk, ensure_ascii=False), }, }, - server_name=FEISHU_MCP_SERVER_NAME, max_chars=API_TOOL_CONTENT_MAX_CHARS, timeout_seconds=60.0, ) diff --git a/tests/test_feishu_integration.py b/tests/test_feishu_integration.py index 2f0b20d..7360c79 100644 --- a/tests/test_feishu_integration.py +++ b/tests/test_feishu_integration.py @@ -130,10 +130,6 @@ class FeishuIntegrationTests(unittest.TestCase): '{"url":"https://mi.feishu.cn/sheets/shtcn123","spreadsheet_token":"shtcn123"}', {'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'}, ), - ( - '{"sheets":[{"sheet_id":"abc123","title":"Sheet1"}]}', - {'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'}, - ), ( '{"ok":true}', {'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'}, @@ -149,12 +145,12 @@ class FeishuIntegrationTests(unittest.TestCase): payload = response.json() self.assertEqual(payload['kind'], 'sheet') self.assertEqual(payload['url'], 'https://mi.feishu.cn/sheets/shtcn123') - self.assertEqual(call_tool.call_count, 3) + self.assertEqual(call_tool.call_count, 2) self.assertEqual(call_tool.call_args_list[0].args[0], 'sheet_ops') self.assertEqual(call_tool.call_args_list[0].kwargs['arguments']['action'], 'create') - self.assertEqual(call_tool.call_args_list[2].kwargs['arguments']['action'], 'write') - write_params = call_tool.call_args_list[2].kwargs['arguments']['params'] - self.assertEqual(write_params['range'], 'abc123!A1:B2') + self.assertEqual(call_tool.call_args_list[1].kwargs['arguments']['action'], 'write') + write_params = call_tool.call_args_list[1].kwargs['arguments']['params'] + self.assertEqual(write_params['range'], 'Sheet1!A1:B2') self.assertEqual( json.loads(write_params['values']), [['query', 'label'], ['导航到公司', 'Agent(tag="地图导航")']], @@ -168,6 +164,42 @@ class FeishuIntegrationTests(unittest.TestCase): online_docs = json.loads(map_path.read_text(encoding='utf-8')) self.assertEqual(online_docs['files'][str(file_path)]['kind'], 'sheet') + def test_create_online_doc_surfaces_feishu_sheet_write_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + client, state = _build_client(Path(tmp_dir)) + file_path = _write_account_file( + state, + 'alice', + 'records.csv', + 'query,label\n导航到公司,地图导航\n', + ) + + with patch.object( + gui_server, + '_feishu_status_payload', + return_value={'logged_in': True, 'status': 'logged_in'}, + ), patch.object( + gui_server.MCPRuntime, + 'call_tool', + side_effect=[ + ( + '{"url":"https://mi.feishu.cn/sheets/shtcn123","spreadsheet_token":"shtcn123"}', + {'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'}, + ), + ( + '{"error":"Sheet not found","code":123}', + {'server_name': 'feishu-mcp-pro', 'tool_name': 'sheet_ops'}, + ), + ], + ): + response = client.post( + '/api/files/online-doc', + json={'account_id': 'alice', 'path': str(file_path)}, + ) + + self.assertEqual(response.status_code, 400) + self.assertIn('Sheet not found', response.json()['detail']) + def test_extract_first_url_ignores_wrapping_quotes(self) -> None: url = gui_server._extract_first_url( '{"url":"https://mi.feishu.cn/docx/CZIldpM3QofbbXxxAq1cEininTd"}'