ChatGPT 插件平台有望成为影响深远的"下一件大事",因此对于开发者来说,有必要对 ChatGPT 插件的开发有一定的了解。原文: Writing a ChatGPT Plugin With Go
我工作的附带福利之一是偶尔可以接触试用一些很酷的新技术,最近的一项技术是 OpenAI 的 ChatGPT 插件平台。
ChatGPT 是一款突破性的 AI 聊天机器人,无论好坏,它都席卷了世界。在 2023 年 3 月,ChatGPT 插件和插件平台的发布引发了持续关注(包括我),这可是件大事。
如果你把 ChatGPT 的发布等同于 2007 年发布 iPhone,那么插件的发布就像是在 2008 年发布的移动应用和 App Store。这不仅是当今最强大、最先进的 AI 模型开发服务平台,也是一个可以立即触达 1 亿用户的机会。
对于像我这样的程序员来说,就像猫薄荷之于猫一样,当我拿到访问权限时,立马跳了起来。
ChatGPT 插件非常简单,实际上只不过是 ChatGPT 调用并获取信息的 API,可以基于返回信息向用户提供答案。是的,你没看错,ChatGPT 插件只是标准 API。
那么,怎么实现 ChatGPT 插件呢?实现 ChatGPT 插件必须提供 2 个文件:
- 描述插件的 manifest 文件(本质上是 JSON 文件)
- 描述插件中 API 的OpenAPI规范(YAML 文件)
接下来看看我是如何创建新加坡 HDB 停车场插件的。
停车场插件
我决定从一个非常简单的插件开始。在新加坡,住房发展局(HDB)是负责公共住房的政府机构。公共住房在新加坡是件大事,约有 80%的人住在公共住房中,因此 HDB 停车场占新加坡住宅停车场的大部分。
我想创建一个插件,可以告诉我关于新加坡 HDB 停车场的使用情况。这些信息都可以在 data.gov.sg 上获得,这是一个免费数据门户网站,提供新加坡政府的公开数据集和 API。
简而言之,插件有两个功能:
- 提供 HDB 停车场的资料,可以指定地点或指定停车场
- 提供 HDB 停车场的可用情况,可以指定地点或指定停车场
data.gov.sg 门户网站有个针对这些目标很有用的 API——HDB停车场使用情况API,这是一个实时 API。至于 HDB 停车场信息,有一个定期更新的数据集,称为HDB停车场信息。幸运的是,HDB 停车场信息数据集是 CKAN action API 的前端。
无论如何,现在这两种功能都有 API 可用。这就完事了,对吧?没那么快。CKAN action API 根据日期参数在数据集中返回所有内容,并不完全有用。我不能把所有数据都转到 ChatGPT 去,数据集是 324kb,超过了 token 限制。然而,解决这个问题相对简单,只需要过滤一下。
此外,我们不能直接公开 API。记住,ChatGPT 插件需要 manifest 文件和 OpenAI 规范。manifest 文件还指向像 logo 和合法页面这样的东西,需要提供具有相同域名和插件 URL 的电子邮件。所有这一切都意味着我们真的需要开发一个单独的 API 程序,在将 data.gov.sg API 发布到 ChatGPT 之前用来进行处理。
API 程序
我用 Go 语言开发了 API 程序,这是很自然的选择,Go 非常适合 API。由于很多 AI 的东西都用 Python,我最近一直在使用 Python,所以也比较渴望回到 Go。
JSON 数据结构
我们从 JSON 数据结构定义开始。
package main import "time" // for car park information type CarParkData struct { CarParkInfo []struct { TotalLots string `json:"total_lots"` LotType string `json:"lot_type"` LotsAvailable string `json:"lots_available"` } `json:"carpark_info"` CarParkNumber string `json:"carpark_number"` UpdateDatetime string `json:"update_datetime"` } type CarParkAvailability struct { Items []struct { Timestamp time.Time `json:"timestamp"` Data []CarParkData `json:"carpark_data"` } `json:"items"` } // for car park availability type CarParkRecord struct { FullCount string `json:"_full_count"` ShortTermParking string `json:"short_term_parking"` CarParkType string `json:"car_park_type"` YCoord string `json:"y_coord"` XCoord string `json:"x_coord"` Rank float64 `json:"rank"` FreeParking string `json:"free_parking"` GantryHeight string `json:"gantry_height"` CarParkBasement string `json:"car_park_basement"` NightParking string `json:"night_parking"` Address string `json:"address"` CarParkDecks string `json:"car_park_decks"` ID int `json:"_id"` CarParkNo string `json:"car_park_no"` TypeOfParkingSystem string `json:"type_of_parking_system"` } type CarParks struct { Help string `json:"help"` Success bool `json:"success"` Result struct { ResourceID string `json:"resource_id"` Fields []struct { Type string `json:"type"` ID string `json:"id"` } `json:"fields"` Q string `json:"q"` Records []CarParkRecord `json:"records"` Links struct { Start string `json:"start"` Next string `json:"next"` } `json:"_links"` Total int `json:"total"` } `json:"result"` }
复制代码
看起来很复杂,但其实我作弊了,我只是从 data.gov.sg API 中获取 JSON 样本和输出,并使用 Matt Holt 提供的JSON-to-Struct服务,该服务会立即创建相应的结构体,然后只需根据需要对其进行调整。
API
接下来是 API 程序本身。
package main import ( "encoding/json" "io" "log" "net/http" "net/url" "os" "time" ) const infoUrl = "https://data.gov.sg/api/action/datastore_search?\ resource_id=139a3035-e624-4f56-b63f-89ae28d4ae4c&q=" const availUrl = "https://api.data.gov.sg/v1/transport/\ carpark-availability?date_time=" func main() { port := os.Getenv("PORT") mux := http.NewServeMux() mux.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir("./static")))) mux.Handle("/.well-known/ai-plugin.json", cors(manifest)) mux.Handle("/openapi.yaml", cors(specs)) mux.Handle("/info", cors(info)) mux.Handle("/availability", cors(availability)) server := &http.Server{ Addr: ":" + port, Handler: mux, } server.ListenAndServe() } // middleware to set CORS for handlers func cors(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET") w.Header().Set("Access-Control-Allow-Headers", "Origin,Content-Type,OpenAI-Conversation-ID,OpenAI-Ephemeral-User-ID") h.ServeHTTP(w, r) }) } // returns openai.yaml file func specs(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "openapi.yaml") } // returns .well-known/ai-plugin.json file func manifest(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, ".well-known/ai-plugin.json") } // handler function // get information on car park func info(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") records := getCarParkRecords(query) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(records) } // handler function // get availability on car park func availability(w http.ResponseWriter, r *http.Request) { // get the car parks requested query := r.URL.Query().Get("q") records := getCarParkRecords(query) // get the availability of car parks in Singapore now now := time.Now() dt := now.Format(time.RFC3339) availability := new(CarParkAvailability) carparkdata := []CarParkData{} resp, err := http.Get(availUrl + url.QueryEscape(dt)) if err != nil { log.Println("Cannot call url", availUrl+dt, err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { log.Println("Cannot read JSON file", err) } err = json.Unmarshal(data, availability) if err != nil { log.Println("Error unmarshalling json data:", err) } // filter off those that we want to look for for _, item := range availability.Items { for _, data := range item.Data { for _, record := range records { if data.CarParkNumber == record.CarParkNo { carparkdata = append(carparkdata, data) } } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(carparkdata) } // get card park records based on a query func getCarParkRecords(query string) []CarParkRecord { carparks := new(CarParks) resp, err := http.Get(infoUrl + query) if err != nil { log.Println("Cannot call url", infoUrl+query, err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { log.Println("Cannot read JSON file", err) } err = json.Unmarshal(data, carparks) if err != nil { log.Println("Error unmarshalling json data:", err) } return carparks.Result.Records }
复制代码
这就是整个 API 程序,大约 120 行左右代码。我们来分析一下。
Manifest 文件
之前提到有两个文件定义 ChatGPT 插件,一个是 manifest 文件,需要放在运行 API 程序的服务器上的.well-known/ai-plugin.json
路径下。
为此,我提供了一个 JSON 文件。如果不需要身份验证,则格式相对简单。
{ "schema_version": "v1", "name_for_human": "HDB Car Park", "name_for_model": "hdbcarpark", "description_for_human": "For checking availability of car park \ lots at various HDB car parks around Singapore.", "description_for_model": "For checking availability of car park \ lots at various HDB car parks around Singapore.", "auth": { "type": "none" }, "api": { "type": "openapi", "url": "https://carpark.sausheong.com/openapi.yaml", "is_user_authenticated": false }, "logo_url": "https://carpark.sausheong.com/static/logo.png", "contact_email": "sausheong@sausheong.com", "legal_info_url": "https://carpark.sausheong.com/static/legal.html" }
复制代码
OpenAPI 规范
第二个是 API 的 OpenAPI 规范。和前面一样,我提供了openapi.yaml
文件,文件位置在上面的 manifest 文件中指定。
openapi: 3.0.1 info: title: HDB Car Park description: For checking avaiability of car park lots at various HDB \ car parks around Singapore version: 'v1' servers: - url: https://carpark.sausheong.com paths: /info?q={location}: get: operationId: getCarParkInfo summary: Get information about HDB car parks at a location parameters: - in: path name: location schema: type: string required: true description: location to search for HDB car parks responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/getCarParkInfo' /availability?q={location}: get: operationId: getCarParkAvailability summary: Get availability of HDB car parks in Singapore at this moment parameters: - in: path name: location schema: type: string required: true description: location to search for HDB car parks responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/getCarParkAvailability' components: schemas: getCarParkInfo: type: array items: type: object properties: _full_count: type: string short_term_parking: type: string car_park_type: type: string y_coord: type: string x_coord: type: string rank: type: number free_parking: type: string gantry_height: type: string car_park_basement: type: string night_parking: type: string address: type: string car_park_decks: type: string _id: type: integer format: int car_park_no: type: string type_of_parking_system: type: string getCarParkAvailability: type: object properties: timestamp: type: string format: date-time carpark_data: type: array items: type: object properties: carpark_info: type: array items: type: object properties: total_lots: type: string lot_type: type: string lots_available: type: string carpark_number: type: string update_datetime: type: string
复制代码
该文件定义了两个路径,/info
和/availability
,用于提供对应的两个功能。这两个路径还可以接受查询参数location
,用于过滤位置。
接下来看看具体功能。
停车场信息
getCarParkRecords
函数直接向 data.gov.sg API 发送 GET 请求,并返回CarParkRecord
结构的切片。
// get card park records based on a query func getCarParkRecords(query string) []CarParkRecord { carparks := new(CarParks) resp, err := http.Get(infoUrl + query) if err != nil { log.Println("Cannot call url", infoUrl+query, err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { log.Println("Cannot read JSON file", err) } err = json.Unmarshal(data, carparks) if err != nil { log.Println("Error unmarshalling json data:", err) } return carparks.Result.Records }
复制代码
info
处理程序使用getCarParkRecords
来获取记录,并作为 JSON 响应返回。
// get information on car park func info(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") records := getCarParkRecords(query) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(records) }
复制代码
停车场使用情况
检查停车场的使用情况稍微有点棘手。availability
处理程序首先调用getCarParkRecords
来获取感兴趣的停车场。
然后调用 data.gov.sg CKAN API 来获取新加坡所有 HDB 停车场的当前使用情况,并放入CarParkAvailability
结构体中。
// get availability on car park func availability(w http.ResponseWriter, r *http.Request) { // get the car parks requested query := r.URL.Query().Get("q") records := getCarParkRecords(query[:len(query)-1]) // get the availability of car parks in Singapore now now := time.Now() dt := now.Format(time.RFC3339) availability := new(CarParkAvailability) carparkdata := []CarParkData{} resp, err := http.Get(availUrl + url.QueryEscape(dt)) if err != nil { log.Println("Cannot call url", availUrl+dt, err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { log.Println("Cannot read JSON file", err) } err = json.Unmarshal(data, availability) if err != nil { log.Println("Error unmarshalling json data:", err) } // filter off those that we want to look for for _, item := range availability.Items { for _, data := range item.Data { for _, record := range records { if data.CarParkNumber == record.CarParkNo { carparkdata = append(carparkdata, data) } } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(carparkdata) }
复制代码
然后遍历CarParkAvailability
和CarParkRecords
中的停车场数据,并匹配停车场编号以过滤CarParkAvailability
。最后,以 JSON 格式返回停车场使用数据。
启用 CORS
需要注意,要为 API 程序启用 CORS(跨域资源共享,Cross-Origin Resource Sharing),从而允许 ChatGPT 调用 API。我将其实现为每个处理程序的 HTTP 中间件。
// middleware to set CORS for handlers func cors(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "https://chat.openai.com") w.Header().Set("Access-Control-Allow-Methods", "GET") w.Header().Set("Access-Control-Allow-Headers", "Origin,Content-Type,OpenAI-Conversation-ID,OpenAI-Ephemeral-User-ID") h.ServeHTTP(w, r) }) }
复制代码
就这么多!
部署插件
ChatGPT 允许我们安装运行在计算机上的插件,也就是说,可以在本地主机上运行,这对于测试和调试非常有用。同时也意味着需要将 manifest 文件和 OpenAPI 规范指向 localhost。
一旦对测试结果感到满意,就可以将其部署到互联网上,让最多 15 个用户进行测试。部署插件类似于部署任何 API,在此不再详述,除了需要为服务提供适当的域名,并且 API 域名需要与 manifest 文件中的联系电子邮件地址相匹配,还需要通过 HTTPS 运行。
一旦部署了 API,就可以将其作为插件安装在 ChatGPT 上。
为什么这么繁琐?
如果你认为对于 API 来说太过于繁琐,只要看看当我在 ChatGPT 上问更复杂的问题时,这个插件是如何回应的。请记住,插件不只是向用户提供数据,也为当今最先进的 AI 模型之一提供数据,以用于回答问题。
我试着比较多层停车场和地面停车场,这很有效,感觉我是在问一个拥有所有数据并能够为我分析数据的人。
问: 在淡滨尼,多层停车场是否比地面停车场有更多可用车位?
ChatGPT: 根据数据,这里是淡滨尼多层停车场和地面停车场的比较:
多层停车场:
总停车位: 26,486
空停车位: 14,070
空停车位占比: 53.1%
地面停车场:
总停车位: 26,486
空停车位: 198
空停车位占比: 18.6%
因此,与地面停车场相比,淡滨尼的多层停车场的可用车位比例更高。
接下来,我试着问哪个停车场车位最多,离这里有多远。
*问: 淡滨尼哪个停车场的车位最多,地址是什么? 离淡滨尼购物中心有多远?
ChatGPT: 淡滨尼可用车位最多的停车场位于 NT5,共有 1489 个车位,其中有 812 个空车位。
此停车场地址为"淡滨尼中环 7 号 510C 座/淡滨尼广场"。
至于到淡滨尼购物中心的距离,我需要计算一下,请稍等。
"NT5"停车场位于"淡滨尼中环 7 号 510C 座/淡滨尼广场",距离淡滨尼购物中心约 500 米,步行大约 6 分钟。请注意,实际时间可能会根据路线和步行速度而有所不同。
这就很有意思了,当 ChatGPT 没有数据时,就会胡编乱造。它不知道停车场离购物中心有多远,所以就编了一个数字,猜测人们从停车场走到购物中心需要多长时间。实际距离为 1 公里,步行时间约为 12 分钟(谷歌地图)。
我还试着让它比较新加坡两个不同地区的停车场使用情况,也很有效。
问: 淡滨尼的空停车位与宏茂桥相比,占停车场容量的百分比如何?
ChatGPT: 以下是淡滨尼和宏茂桥的停车场可用情况:
淡滨尼:
总停车位: 13,739
空停车位: 4,751
空停车位占比: 34.6%
宏茂桥:
总停车位: 30,544
空停车位: 9,380
空停车位占比: 30.7%
请注意,这些数字是实时更新的,随时可能会发生变化。
总结
编写 ChatGPT 插件非常简单,并且是一个强大的工具。编写插件的技能与目前技能没什么不同,不像刚开始开发移动应用时需要更多专业技能。很容易看出,当它正式发布时,很可能会有一个插件热潮。更令人兴奋的是,插件可以相互协作以提供协同功能。
ChatGPT 仍然存在问题,当数据不可用时,会产生幻觉现象。然而,从长远来看,这是可以解决的。我认为一个潜力巨大的 AI 市场正在开始。
代码
可以从下面 Git 仓库获得所有代码。
ChatGPT plugin for Singapore HDB car park availability
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。微信公众号:DeepNoMind