«Построение интеллектуального помощника по составлению маршрутов путешествия с использованием LangChain, Google Maps API и Gradio (Часть 2)»

«Создание умного помощника для планирования путешествий с использованием LangChain, Google Maps API и Gradio (Часть 2)»

здесь.

1. Резюме части 1

В первой части этой серии из трех частей мы использовали LangChain и инженерию запросов, чтобы создать систему, которая делает последовательные вызовы к LLM API – либо Google PaLM, либо ChatGPT от OpenAI, который преобразует запрос пользователя в маршрут путешествия и красиво разобранный список адресов. Теперь пришло время узнать, как мы можем взять этот список адресов и преобразовать его в маршрут путешествия с указанием направлений на карте. Для этого мы в основном будем использовать API Google Maps с помощью пакета googlemaps. Мы также использовать folium для создания графиков. Давайте начнем!

2. Подготовка к вызовам API

Для создания ключа API для Google Maps вам сначала нужно создать аккаунт в Google Cloud. У них есть пробный период бесплатного использования в течение 90 дней, после чего вам придется платить за используемые вами услуги API аналогичным образом, как вы делаете это с OpenAI. После завершения регистрации вы можете создать проект (я назвал его LLMMapper) и перейти в раздел Google Maps Platform на сайте Google Cloud. Оттуда вы сможете получить доступ к меню «Ключи и учетные данные» для создания API-ключа. Вы также можете ознакомиться с меню «API и сервисы», чтобы изучить множество услуг, которые предоставляет Google Maps Platform. В этом проекте мы будем использовать только службы Directions и Geocoding. Мы будем геокодировать каждую из наших путевых точек, а затем находить направления между ними.

Скриншот, показывающий навигацию к меню «Ключи и учетные данные» на сайте Google Maps Platform. Здесь вы создадите API-ключ.

Теперь ключ API Google Maps можно добавить в ранее настроенный файл .env

OPENAI_API_KEY = {ваш ключ OpenAI}GOOGLE_PALM_API_KEY = {ваш ключ Google Palm api}GOOGLE_MAPS_API_KEY = {ваш ключ Google Maps API здесь}

Чтобы проверить, работает ли это, загрузите секреты из файла .env, используя метод, описанный в части 1. Затем мы можем попытаться вызвать геокодирование следующим образом

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 Maps может сопоставить предоставленную строку с адресом и подробностями реального места и должен вернуть список, похожий на этот

[{'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 Maps правильно сопоставил его с точным адресом, координатами и другой информацией, которая может быть полезна разработчику, в зависимости от приложения. Здесь нам понадобятся только поля formatted_address и place_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 Maps знал, что порядок промежуточных точек может быть изменен для снижения общего времени путешествия. Мы также можем указать тип транспорта, который по умолчанию является driving, если не указано иное. Напомним, что в части 1 мы попросили LLM вернуть тип транспорта вместе с предложением маршрута, поэтому в теории мы также могли бы использовать это здесь.

Словарь, возвращаемый из вызова API направлений, имеет следующие ключи

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

Из этой информации наиболее полезными для нас будут legs и overview_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 Maps генерирует для сжатия длинного списка значений широты и долготы в строку. Они представляют собой закодированные строки, которые выглядят как

“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_dict и build_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]# устанавливаем центр карты в начальной точке маршрутамap_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)# Добавляем маркеры в качестве красных точек для каждого места    folium.Marker(        location=location,        popup=address,        tooltip="<strong>Click for address</strong>",        icon=folium.Icon(color="red", icon="info-sign"),    ).add_to(map)# Добавляем маршрут в виде синей линииплощадь_группы = folium.FeatureGroup("Route overview")folium.vector_layers.PolyLine(    route_coords,    popup="<b>Overall route</b>",    tooltip="This is a tooltip where we can add distance and duration",    color="blue",    weight=2,).add_to(f_group)f_group.add_to(map)

При запуске этого кода Folium сгенерирует интерактивную карту, которую мы можем изучать и щелкать каждую из точек маршрута.

Interactive map generated from the result of a Google Maps API call

4. Уточнение маршрута

Подход, описанный выше, когда мы делаем один вызов к API Google Maps с перечнем точек маршрута, а затем отображаем overview_polyline, отлично подходит в качестве POC, но есть несколько проблем:

  1. Вместо formatted_address более эффективно использовать place_id при указании начальной, конечной и добавочной точек в вызове Google Maps. К счастью, мы получаем place_id в результате наших вызовов геокодирования, поэтому мы должны использовать его.
  2. Количество точек маршрута, которые могут быть запрошены в одном API-вызове, ограничено 25 (см. https://developers.google.com/maps/documentation/directions/get-directions для получения подробностей). Если у нас есть более 25 остановок в нашем маршруте из LLM, нам нужно сделать больше вызовов к Google Maps, а затем объединить ответы
  3. overview_polyline имеет ограниченное разрешение при увеличении масштаба, вероятно, потому что количество точек вдоль него оптимизировано для просмотра крупномасштабной карты. Это не является серьезной проблемой для POC, но было бы хорошо иметь больший контроль над разрешением маршрута, чтобы он выглядел хорошо даже на высоких уровнях масштабирования. API маршрутов предоставляет нам более детализированные полилинии в маршрутных сегментах, поэтому мы можем воспользоваться ими.
  4. На карте было бы здорово разделить маршрут на отдельные сегменты и позволить пользователю видеть расстояние и время путешествия, связанные с каждым из них. Снова, Google Maps предоставляет нам эту информацию, поэтому мы должны использовать ее.
Разрешение overview_polyline ограничено. Здесь мы приближаемся к Санта-Барбаре, и не очевидно, какие дороги нам следует выбирать.

Проблема 1 может быть легко решена, просто модифицируя build_directions_and_route для использования place_id из mapping_dict, а не formatted_address. Проблема 2 требует более глубокого изучения и требует разделения наших начальных точек на сегменты с определенной максимальной длиной, создание начала, конца и подсписка точек маршрута для каждого их них, а затем выполнение build_mapping_dict и build_directions_and_route на них. Результаты могут быть объединены в конце.

Проблемы 3 и 4 можно решить, используя отдельные step полилинии для каждого участка маршрута, возвращаемого Google Maps. Нам просто нужно перебрать эти два уровня, расшифровать соответствующие полилинии и затем построить новый словарь. Это также позволяет извлечь значения расстояния и длительности, которые присваиваются каждому расшифрованному участку и затем используются для построения.

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 точек), а затем определить, какую долю должно составлять каждый участок маршрута, а затем равномерно выбрать соответствующее количество точек из каждого участка. Обратите внимание, что мы должны убедиться, что каждый участок содержит хотя бы одну точку, чтобы он был включен на карте.

Следующая функция будет выполнять эту выборку, принимая в качестве входных данных словарь waypoints, вывод, полученный из функции get_route выше.

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 (см. часть 1) и генерирует выбранный маршрут. Второй – это RouteMapper, который принимает выбранный маршрут и строит карту folium, которую можно сохранить в формате html.

Поскольку мы практически всегда хотим создать карту, когда обращаемся за маршрутом, метод generate_route в RouteFinder обрабатывает оба этих задания

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

Напомним, что в части 1 мы создали класс с названием Agent, который обрабатывал вызовы LLM. Теперь, когда у нас есть также RouteFinder, мы можем объединить их в базовый класс для всего проекта travel mapper

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 = """        Я хочу совершить 2-недельное путешествие из Беркли, Калифорния в Нью-Йорк.        Я хочу посетить национальные парки и города с хорошей едой.        Я хочу использовать арендованное авто и ежедневно не превышать 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. Любые предложения по улучшению или расширению функциональности будут высоко оценены!