使用LangChain、Google 地图 API 和Gradio 构建智能旅行行程建议生成器(第二部分)

如何用LangChain、Google 地图 API 和Gradio 建造智能旅行行程建议生成器(第二部分)

学习如何构建一个可能激发你下一次公路旅行的应用程序

本文是一个三部分系列的第2部分,我们使用OpenAI和Google API构建一个旅行行程建议应用程序,并在生成的简单UI中显示它。在这一部分中,我们讨论了如何使用Google地图API和folium从途经点列表中生成一个交互式路线地图。只想看代码?在这里找到它 here

1. 第1部分回顾

在这个三部分系列的第一部分中,我们使用LangChain和提示工程来构建一个系统,该系统对LLM API进行顺序调用,转换用户的查询为旅行行程和一个经过良好解析的地址列表。现在是时候看看我们如何将那些地址列表转换为一条地图上带有方向指示的旅行路线了。为了做到这一点,我们将主要使用Google地图API,通过googlemaps包。我们还将使用folium进行绘图。让我们开始吧!

2. 准备好进行API调用

为了为Google地图创建一个API密钥,您首先需要在Google Cloud上创建一个帐号。他们提供一个90天的免费试用期,之后您需要按照与OpenAI相似的方式为您使用的API服务付费。完成后,您可以创建一个项目(我的项目名为LLMMapper)并访问Google Cloud网站上的Google地图平台部分。从那里,您应该能够访问“密钥和凭据”菜单以生成一个API密钥。您还应该查看“API和服务”菜单,以了解Google地图平台提供的许多服务。对于这个项目,我们将只使用方向和地理编码服务。我们将对我们的每个途经点进行地理编码,然后找到它们之间的路线。

Screenshot showing navitation to the Keys & Credentials menu of the Google Maps Platform site. This is where you will make an API key.

现在,您可以将Google地图API密钥添加到之前设置的.env文件中

OPENAI_API_KEY = {你的OpenAI密钥}GOOGLE_PALM_API_KEY = {您的Google Palm API密钥}GOOGLE_MAPS_API_KEY = {您的Google地图API密钥}

为了测试是否工作正常,使用第一部分中描述的方法从.env加载秘钥。然后,我们可以尝试进行以下地理编码调用

import googlemapsdef convert_to_coords(input_address):    return self.gmaps.geocode(input_address)secrets = load_secets()gmaps = googlemaps.Client(key=secrets["GOOGLE_MAPS_API_KEY"])example_coords = convert_to_coords("The Washington Moment, DC")

Google地图能够将提供的字符串与实际位置的地址和详细信息匹配,并应该返回如下列表

[{'address_components': [{'long_name': '2',    'short_name': '2',    'types': ['street_number']},   {'long_name': '15th Street Northwest',    'short_name': '15th St NW',    'types': ['route']},   {'long_name': 'Washington',    'short_name': 'Washington',    'types': ['locality', 'political']},   {'long_name': 'District of Columbia',    'short_name': 'DC',    'types': ['administrative_area_level_1', 'political']},   {'long_name': 'United States',    'short_name': 'US',    'types': ['country', 'political']},   {'long_name': '20024', 'short_name': '20024', 'types': ['postal_code']}],  'formatted_address': '2 15th St NW, Washington, DC 20024, USA',  'geometry': {'location': {'lat': 38.8894838, 'lng': -77.0352791},   'location_type': 'ROOFTOP',   'viewport': {'northeast': {'lat': 38.89080313029149,     'lng': -77.0338224697085},    'southwest': {'lat': 38.8881051697085, 'lng': -77.0365204302915}}},  'partial_match': True,  'place_id': 'ChIJfy4MvqG3t4kRuL_QjoJGc-k',  'plus_code': {'compound_code': 'VXQ7+QV Washington, DC',   'global_code': '87C4VXQ7+QV'},  'types': ['establishment',   'landmark',   'point_of_interest',   'tourist_attraction']}]

这非常强大!尽管请求有些模糊,但Google地图服务已经将其正确匹配到了一个具体的地址,带有坐标和其他可能对开发人员有用的区域信息。在这里,我们只需要使用formatted_addressplace_id字段。

3. 构建路线

地理编码对于我们的旅行地图应用程序非常重要,因为地理编码API似乎更擅长处理模糊或部分不完整的地址,而不是方向API。从LLM调用中得到的地址不能保证包含足够的信息,以便方向API能够给出一个良好的响应,因此先进行地理编码步骤可以减少错误的可能性。

让我们首先调用地理编码器,对起点、终点和中间路点列表进行调用,并将结果存储在一个字典中

   def build_mapping_dict(start, end, waypoints):    mapping_dict = {}    mapping_dict["start"] = self.convert_to_coords(start)[0]    mapping_dict["end"] = self.convert_to_coords(end)[0]        if waypoints:      for i, waypoint in enumerate(waypoints):          mapping_dict["waypoint_{}".format(i)] = convert_to_coords(                    waypoint                )[0    return mapping_dict

现在,我们可以使用方向API获取从起点到终点的包括路点的路线

    def build_directions_and_route(        mapping_dict, start_time=None, transit_type=None, verbose=True    ):    if not start_time:        start_time = datetime.now()    if not transit_type:        transit_type = "driving"            # 这里稍后将它替换为place_id,这更高效      waypoints = [            mapping_dict[x]["formatted_address"]            for x in mapping_dict.keys()            if "waypoint" in x      ]      start = mapping_dict["start"]["formatted_address"]      end = mapping_dict["end"]["formatted_address"]      directions_result = gmaps.directions(            start,            end,            waypoints=waypoints,            mode=transit_type,            units="metric",            optimize_waypoints=True,            traffic_model="best_guess",            departure_time=start_time,      )      return directions_result

方向API的完整文档在这里,有许多不同的可指定选项。请注意,我们指定了路线的起点和终点以及路点列表,并选择optimize_waypoints=True,这样Google地图就知道路点的顺序可以更改以减少总体旅行时间。我们也可以指定运输方式,默认为driving,除非另有设置。回想一下,在第一部分中,我们要求LLM返回运输方式以及其行程建议,因此理论上我们也可以在这里使用它。

从方向API调用返回的字典具有以下键

['bounds', 'copyrights', 'legs', 'overview_polyline', 'summary', 'warnings', 'waypoint_order']

从这些信息中,legsoverview_polyline对我们最有用。 legs是一系列路线片段,每个元素如下所示

['distance', 'duration', 'end_address', 'end_location', 'start_address', 'start_location', 'steps', 'traffic_speed_entry', 'via_waypoint']

每个leg进一步分为steps,这是一系列逐步说明及其相关的路线段的集合。这是一个带有以下键的字典列表

['distance', 'duration', 'end_location', 'html_instructions', 'polyline', 'start_location', 'travel_mode']

polyline键存储了实际路线信息。每个路线是一串已编码的坐标列表,Google地图生成它作为将一长串纬度和经度值压缩成字符串的手段。它们是编码成如下样式的字符串

“e|peFt_ejVjwHalBzaHqrAxeE~oBplBdyCzpDif@njJwaJvcHijJ~cIabHfiFyqMvkFooHhtE}mMxwJgqK”

你可以在这里阅读更多关于这个的内容,但幸运的是我们可以使用decode_polyline工具将它们转换回坐标。例如

from googlemaps.convert import decode_polylineoverall_route = decode_polyline(directions_result[0]["overview_polyline"]["points"])route_coords = [(float(p["lat"]),float(p["lng"])) for p in overall_route]

这将给出一条线路上的纬度和经度点的列表。

这是我们绘制简单地图所需的全部信息,显示路线上的途径点和连接它们的正确行驶路径。我们可以使用overview_polyline作为起点,尽管后面我们会看到,这会导致地图高缩放级别下的分辨率问题。

假设我们从以下查询开始:

“我想从旧金山到拉斯维加斯做一次为期5天的公路旅行。我想在HW1沿线参观漂亮的沿海小镇,然后在加利福尼亚州南部观赏山景”

我们的LLM调用提取了一个途径点的字典,然后我们运行build_mapping_dictbuild_directions_and_route从Google Maps获取我们的方向结果

我们可以先这样提取途径点

marker_points = []nlegs = len(directions_result[0]["legs"])for i, leg in enumerate(directions_result[0]["legs"]):  start, start_address = leg["start_location"], leg["start_address"]  end,  end_address = leg["end_location"], leg["end_address"]  start_loc = (float(start["lat"]),float(start["lng"]))  end_loc = (float(end["lat"]),float(end["lng"]))  marker_points.append((start_loc,start_address))  if i == nlegs-1:    marker_points.append((end_loc,end_address))

现在,使用folium和branca,我们可以绘制一个漂亮的交互式地图,它应该在Colab或Jupyter Notebook中显示

import foliumfrom branca.element import Figurefigure = Figure(height=500, width=1000)# 解码路线overall_route = decode_polyline(  directions_result[0]["overview_polyline"]["points"])route_coords = [(float(p["lat"]),float(p["lng"])) for p in overall_route]# 将地图中心设置为路线的起始位置map_start_loc = [overall_route[0]["lat"],overall_route[0]["lng"]]map = folium.Map(  location=map_start_loc,   tiles="Stamen Terrain",   zoom_start=9)figure.add_child(map)# 添加途径点为红色标记for location, address in marker_points:    folium.Marker(        location=location,        popup=address,        tooltip="<strong>点击查看地址</strong>",        icon=folium.Icon(color="red", icon="info-sign"),    ).add_to(map)# 将路线作为蓝色线添加到地图中f_group = folium.FeatureGroup("路线概览")folium.vector_layers.PolyLine(    route_coords,    popup="<b>整体路线</b>",    tooltip="这是一个可以添加距离和持续时间的工具提示",    color="blue",    weight=2,).add_to(f_group)f_group.add_to(map)

运行此代码时,Folium将生成一个交互式地图,我们可以探索并点击每个途径点。

从Google Maps API调用结果生成的交互式地图

4. 优化路线

上述方法,在调用Google Maps方向API时传递一个途径点列表并绘制overview_polyline,作为概念验证是非常好的,但有一些问题:

  1. 在将起点、终点和途经点的名称指定给Google Maps时,使用place_id比使用formatted_address更高效。幸运的是,在我们的地理编码调用结果中,我们得到了place_id,所以我们应该使用它。
  2. 单个API调用中可以请求的途径点数量限制为25(有关详细信息,请参见https://developers.google.com/maps/documentation/directions/get-directions)。如果我们的行程中有超过25个途径点,我们需要多次调用Google Maps,然后合并响应。
  3. overview_polyline在缩放时有限的分辨率,可能是因为其中的点数针对大规模地图视图进行了优化。这对于概念验证来说不是一个主要问题,但是在高缩放级别下,如果能有一些对路线分辨率的更多控制,使其看起来更好,那将是很好的。方向API在提供的路段中提供了更多细粒度的折线,所以我们可以利用它们。
  4. 在地图上,我们可以将路线分割成单独的路段,并允许用户查看与每个路段相关的距离和行驶时间。同样,Google Maps为我们提供了这些信息,所以我们应该利用它们。
路线轮廓的分辨率有限。 在这里,我们放大了圣巴巴拉,但不明显我们应该走哪条路。

问题1可以通过修改build_directions_and_route来解决,使用mapping_dict中的place_id而不是formatted_address。问题2要复杂一些,需要将初始的路标分成一些最大长度的块,从每个块中创建起点、终点和子路标的子列表,然后对其运行build_mapping_dict,然后再对其运行build_directions_and_route。然后将结果拼接在一起。

问题3和4可以通过使用Google地图返回的每个路线的单独步骤轮廓来解决。我们只需要循环这两个层级,解码相关的轮廓,然后构建一个新的字典。这还可以使我们提取距离和持续时间值,这些值分配给每个解码的路线段,然后用于绘制。

def get_route(directions_result):    waypoints = {}    for leg_number, leg in enumerate(directions_result[0]["legs"]):        leg_route = {}                distance, duration = leg["distance"]["text"], leg["duration"]["text"]        leg_route["distance"] = distance        leg_route["duration"] = duration        leg_route_points = []                for step in leg["steps"]:             decoded_points = decode_polyline(step["polyline"]["points"])            for p in decoded_points:              leg_route_points.append(f'{p["lat"]},{p["lng"]}')            leg_route["route"] = leg_route_points            waypoints[leg_number] = leg_route    return waypoints

现在的问题是leg_route_points列表可能会变得非常长,当我们在地图上绘制它时,可能会导致folium崩溃或运行非常慢。解决方案是对路线进行采样,以使有足够的点进行良好的可视化,但不会出现加载地图有困难的情况。

进行这种采样的一种简单而安全的方法是计算总路线应包含的点的数量(假设为5000个点),然后确定每个路径的比例,然后均匀采样相应数量的点。请注意,我们需要确保每个路径至少包含一个点,才能在地图上绘制它。

下面的函数将对此进行采样,接受get_route函数输出的waypoints字典作为输入。

def sample_route_with_legs(route, distance_per_point_in_km=0.25):        all_distances = sum([float(route[i]["distance"].split(" ")[0]) for i in route])    # 总采样点数    npoints = int(np.ceil(all_distances / distance_per_point_in_km))        # 每个路径的总采样点数    points_per_leg = [len(v["route"]) for k, v in route.items()]    total_points = sum(points_per_leg)    # 获取每个路径需要表示的总采样点数    number_per_leg = [      max(1, np.round(npoints * (x / total_points), 0)) for x in points_per_leg      ]    sampled_points = {}    for leg_id, route_info in route.items():        total_points = int(points_per_leg[leg_id])        total_sampled_points = int(number_per_leg[leg_id])        step_size = int(max(total_points // total_sampled_points, 1.0))        route_sampled = [                route_info["route"][idx] for idx in range(0, total_points, step_size)            ]        distance = route_info["distance"]        duration = route_info["duration"]        sampled_points[leg_id] = {                "route": [                    (float(x.split(",")[0]), float(x.split(",")[1]))                    for x in route_sampled                ],                "duration": duration,                "distance": distance,            }    return sampled_points

在这里,我们指定了所需的点的间距(每250米一个点),然后根据这个选择点的数量。我们还可以考虑通过路线的长度来估算所需的点间距,但是这种方法在第一次尝试时似乎工作得相当好,可以在地图上以适度高的缩放级别获得可接受的分辨率。

现在我们将路线分成合理数量的样本点,然后可以用以下代码在地图上绘制它们并标注每一段:

for leg_id, route_points in sampled_points.items():    leg_distance = route_points["distance"]    leg_duration = route_points["duration"]    f_group = folium.FeatureGroup("第 {} 段".format(leg_id))    folium.vector_layers.PolyLine(                route_points["route"],                popup="<b>路段 {}</b>".format(leg_id),                tooltip="距离:{}, 时间:{}".format(leg_distance, leg_duration),                color="蓝色",                weight=2,    ).add_to(f_group)    # 假设地图已经生成好了    f_group.add_to(map)
对一段已标记和注释的路线的示例,使其显示在地图上

5. 把所有东西放在一起

在代码库中,所有上述的方法都封装在两个类中。第一个是 RouteFinder,它接收结构化的 Agent 输出(见第一部分),并生成采样路线。第二个是 RouteMapper,它接收采样路线并绘制一个 folium 地图,可以保存为 HTML。

由于我们几乎总是在请求路线时想要生成地图,所以 RouteFindergenerate_route 方法处理了这两个任务。

class RouteFinder:    MAX_WAYPOINTS_API_CALL = 25    def __init__(self, google_maps_api_key):        self.logger = logging.getLogger(__name__)        self.logger.setLevel(logging.INFO)        self.mapper = RouteMapper()        self.gmaps = googlemaps.Client(key=google_maps_api_key)    def generate_route(self, list_of_places, itinerary, include_map=True):        self.logger.info("# " * 20)        self.logger.info("建议行程")        self.logger.info("# " * 20)        self.logger.info(itinerary)        t1 = time.time()        directions, sampled_route, mapping_dict = self.build_route_segments(            list_of_places        )        t2 = time.time()        self.logger.info("构建路线时间:{}".format((round(t2 - t1, 2))))        if include_map:            t1 = time.time()            self.mapper.add_list_of_places(list_of_places)            self.mapper.generate_route_map(directions, sampled_route)            t2 = time.time()            self.logger.info("生成地图时间:{}".format((round(t2 - t1, 2))))        return directions, sampled_route, mapping_dict

回想一下,在第一部分,我们构建了一个叫做 Agent 的类,它处理地点和景点的呼叫。现在我们还有了 RouteFinder,我们可以将它们放在整个旅行映射项目的基类中。

class TravelMapperBase(object):    def __init__(        self, openai_api_key, google_palm_api_key, google_maps_key, verbose=False    ):        self.travel_agent = Agent(            open_ai_api_key=openai_api_key,            google_palm_api_key=google_palm_api_key,            debug=verbose,        )        self.route_finder = RouteFinder(google_maps_api_key=google_maps_key)    def parse(self, query, make_map=True):        itinerary, list_of_places, validation = self.travel_agent.suggest_travel(query)        directions, sampled_route, mapping_dict = self.route_finder.generate_route(            list_of_places=list_of_places, itinerary=itinerary, include_map=make_map        )

以下是运行此查询的方法,其中是在 test_without_gradio 脚本中给出的示例:

from travel_mapper.TravelMapper import load_secrets, assert_secretsfrom travel_mapper.TravelMapper import TravelMapperBasedef test(query=None):    secrets = load_secrets()    assert_secrets(secrets)    if not query:        query = """        我想从加利福尼亚大学伯克利分校到纽约市进行两周的旅行。        我想参观国家公园和有美食的城市。        我想租一辆汽车,在任何给定的一天里不开车超过 5 小时。        """    mapper = TravelMapperBase(        openai_api_key=secrets["OPENAI_API_KEY"],        google_maps_key=secrets["GOOGLE_MAPS_API_KEY"],        google_palm_api_key=secrets["GOOGLE_PALM_API_KEY"],    )    mapper.parse(query, make_map=True)

在路径和地图生成方面,我们已经完成了!但是如何将所有这些代码打包成一个漂亮且易于实验的用户界面呢?这将在这个系列的第三部分进行讲解。

感谢您的阅读!请随意在这里https://github.com/rmartinshort/travel_mapper探索完整的代码库。对改进或扩展功能的任何建议将不胜感激!