Costruire un Smart Travel Itinerary Suggester con LangChain, Google Maps API e Gradio (Parte 2)

Creare un Suggeritore di Itinerario Smart per i Viaggi con LangChain, Google Maps API e Gradio (Parte 2)

Impara come creare un’applicazione che potrebbe ispirare il tuo prossimo viaggio in autostrada

Questo articolo è la parte 2 di una serie di tre parti in cui creiamo un’applicazione suggeritrice di itinerari di viaggio utilizzando OpenAI e le API di Google e la visualizziamo in un’interfaccia utente semplice generata con gradio. In questa parte, discuteremo come utilizzare le API di Google Maps e folium per generare una mappa interattiva dei percorsi da una lista di punti intermedi. Vuoi solo vedere il codice? Trovalo qui.

1. Riepilogo della parte 1

Nella prima parte di questa serie di tre parti, abbiamo utilizzato LangChain e l’ingegnerizzazione del prompt per creare un sistema che effettua chiamate sequenziali a un’API LLM, sia il PaLM di Google che il ChatGPT di OpenAI, che converte la query dell’utente in un itinerario di viaggio e una lista di indirizzi ben formattati. Ora è il momento di vedere come possiamo prendere quella lista di indirizzi e convertirla in un percorso di viaggio con le indicazioni disegnate su una mappa. Per fare ciò, faremo principalmente uso delle API di Google Maps tramite il pacchetto googlemaps. Utilizzeremo anche folium per la visualizzazione. Iniziamo!

2. Preparazione per effettuare chiamate API

Per creare una chiave API per Google Maps, dovrai prima creare un account con Google Cloud. Hanno un periodo di prova gratuito di 90 giorni, dopodiché pagherai per i servizi API che utilizzi in modo simile a quanto fai con OpenAI. Una volta completato questo passaggio, puoi creare un progetto (il mio si chiama LLMMapper) e accedere alla sezione Google Maps Platform del sito di Google Cloud. Da lì, dovresti essere in grado di accedere al menu “Key e credenziali” per generare una chiave API. Dovresti anche esplorare il menu “API e servizi” per scoprire i numerosi servizi offerti da Google Maps Platform. Per questo progetto useremo solo i servizi di indicazioni stradali e geocodifica. Geocoderemo ciascuno dei nostri punti intermedi e quindi troveremo le indicazioni tra di essi.

Screenshot che mostra la navigazione al menu Key e credenziali del sito Google Maps Platform. Qui creerai una chiave API.

Ora, la chiave API di Google Maps può essere aggiunta al file .env che abbiamo impostato in precedenza

OPENAI_API_KEY = {tua chiave open ai}GOOGLE_PALM_API_KEY = {tua chiave API di Google Palm}GOOGLE_MAPS_API_KEY = {la tua chiave API di Google Maps}

Per testare se funziona, carica i segreti dal file .env utilizzando il metodo descritto nella parte 1. Possiamo quindi effettuare una chiamata di geocodifica come segue

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 è in grado di abbinare la stringa fornita all’indirizzo e ai dettagli di un luogo effettivo e dovrebbe restituire una lista come questa

[{'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']}]

Questo è molto potente! Sebbene la richiesta sia un po’ vaga, il servizio di Google Maps ha correttamente abbinato tutto a un indirizzo esatto con coordinate e altre informazioni locali che potrebbero essere utili per uno sviluppatore a seconda dell’applicazione. Qui avremo solo bisogno dei campi formatted_address e place_id.

3. Costruire il percorso

La geocodifica è importante per la nostra applicazione di mapping dei viaggi perché l’API di geocodifica sembra essere più adatta a gestire indirizzi vaghi o parzialmente completi rispetto all’API delle indicazioni. Non c’è alcuna garanzia che gli indirizzi provenienti dalle chiamate LLM contengano sufficienti informazioni per consentire all’API delle indicazioni di dare una buona risposta, quindi fare questa fase di geocodifica innanzitutto riduce la possibilità di errori.

Chiamiamo prima il geocodificatore sul punto di partenza, il punto di arrivo e la lista dei punti intermedi e memorizziamo i risultati in un dizionario.

   def costruisci_dizionario_tracciamento(inizio, fine, punti_intermedi):    dizionario_tracciamento = {}    dizionario_tracciamento["inizio"] = self.converti_in_coordinate(inizio)[0]    dizionario_tracciamento["fine"] = self.converti_in_coordinate(fine)[0]        if punti_intermedi:      per i, punto_intermedio in enumerate(punti_intermedi):          dizionario_tracciamento["punto_intermedio_{}".format(i)] = converti_in_coordinate(                    punto_intermedio                )[0    return dizionario_tracciamento

Ora possiamo utilizzare l’API delle indicazioni per ottenere il percorso dall’inizio alla fine che include i punti intermedi

    def costruisci_indicazioni_e_percorso(        dizionario_tracciamento, ora_inizio=None, tipo_trasporto=None, verboso=True    ):    if not ora_inizio:        ora_inizio = datetime.now()    if not tipo_trasporto:        tipo_trasporto = "guida"            # in seguito sostituiremo questo con place_id, che è più efficiente      punti_intermedi = [            dizionario_tracciamento[x]["formatted_address"]            for x in dizionario_tracciamento.keys()            if "punto_intermedio" in x      ]      inizio = dizionario_tracciamento["inizio"]["formatted_address"]      fine = dizionario_tracciamento["fine"]["formatted_address"]      risultato_indicazioni = gmaps.directions(            inizio,            fine,            waypoints=punti_intermedi,            mode=tipo_trasporto,            units="metrico",            optimize_waypoints=True,            traffic_model="best_guess",            departure_time=ora_inizio,      )      return risultato_indicazioni

La documentazione completa per l’API delle indicazioni è qui, e ci sono molte opzioni diverse che possono essere specificate. Nota che specifichiamo l’inizio e la fine del percorso insieme alla lista dei punti intermedi e scegliamo optimize_waypoints=True in modo che Google Maps sappia che l’ordine dei punti intermedi può essere modificato per ridurre il tempo di viaggio totale. Possiamo anche specificare il tipo di trasporto, che di default è guida a meno che non venga modificato. Ricorda che nella parte 1 abbiamo chiesto all’LLM di restituire anche il tipo di trasporto insieme alla sua proposta di itinerario, quindi in teoria potremmo utilizzare anche quello qui.

Il dizionario restituito dalla chiamata all’API delle indicazioni ha le seguenti chiavi

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

Di queste informazioni, legs e overview_polyline saranno le più utili per noi. legs è una lista di segmenti di percorso, ciascun elemento ha un aspetto come questo

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

Ogni leg è ulteriormente suddiviso in steps, che è la raccolta di istruzioni dettagliate e dei loro segmenti di percorso associati. Questo è una lista di dizionari con le seguenti chiavi

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

Le chiavi polyline sono dove sono memorizzate le informazioni effettive del percorso. Ogni polilinea è una rappresentazione codificata di una lista di coordinate, che Google Maps genera come mezzo per comprimere una lunga lista di valori di latitudine e longitudine in una stringa. Sono stringhe codificate che hanno un aspetto come

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

Puoi leggere di più su questo qui, ma fortunatamente possiamo utilizzare l’utilità decode_polyline per convertirli nuovamente in coordinate. Per esempio

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

Questo fornirà una lista di punti di latitudine e longitudine lungo il percorso.

Questo è tutto ciò che dobbiamo sapere per tracciare una semplice mappa mostrando i punti di sosta lungo un percorso e i percorsi di guida corretti che li collegano. Possiamo utilizzare l’overview_polyline come punto di partenza, anche se vedremo più avanti che ciò può causare problemi di risoluzione a zoom elevati della mappa.

Supponiamo di aver iniziato con la seguente query:

“Voglio fare un viaggio in macchina di 5 giorni da San Francisco a Las Vegas. Voglio visitare graziose città costiere lungo HW1 e poi ammirare le viste montane nel sud della California”

Le nostre chiamate LLM hanno estratto un dizionario di punti di sosta e abbiamo eseguito build_mapping_dict e build_directions_and_route per ottenere i risultati delle indicazioni da Google Maps

Possiamo prima estrarre i punti di sosta in questo modo

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))

Ora, utilizzando folium e branca, possiamo tracciare una bella mappa interattiva che dovrebbe apparire in Colab o Jupyter Notebook

import folium
from branca.element import Figure
figure = Figure(height=500, width=1000)
# decodifica del percorso
overall_route = decode_polyline(directions_result[0]["overview_polyline"]["points"])
route_coords = [(float(p["lat"]),float(p["lng"])) for p in overall_route]
# impostare il centro della mappa sulla posizione di partenza del percorso
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)
# Aggiungi i punti di sosta come marker rossi
for location, address in marker_points:
    folium.Marker(
        location=location,
        popup=address,
        tooltip="<strong>Clicca per l'indirizzo</strong>",
        icon=folium.Icon(color="red", icon="info-sign"),
    ).add_to(map)
# Aggiungi il percorso come una linea blu
f_group = folium.FeatureGroup("Panoramica del percorso")
folium.vector_layers.PolyLine(
    route_coords,
    popup="<b>Percorso complessivo</b>",
    tooltip="Questo è un tooltip in cui possiamo aggiungere distanza e durata",
    color="blue",
    weight=2,).add_to(f_group)
f_group.add_to(map)

Quando viene eseguito questo codice, Folium genererà una mappa interattiva che possiamo esplorare e su cui possiamo fare clic sui punti di sosta.

Mappa interattiva generata dal risultato di una chiamata all'API di Google Maps

4. Migliorare il percorso

L’approccio descritto sopra, in cui effettuiamo una singola chiamata all’API delle indicazioni di Google Maps con un elenco di punti di sosta e quindi tracciamo l’overview_polyline, funziona molto bene come prova concettuale, ma ci sono alcune questioni:

  1. Al posto di formatted_address, è più efficiente utilizzare place_id quando specifica i nomi di partenza, destinazione e punti di sosta nella chiamata a Google Maps. Fortunatamente otteniamo place_id nel risultato delle nostre chiamate di geocoding, quindi dovremmo utilizzarlo.
  2. Il numero di punti di sosta che possono essere richiesti in una singola chiamata all’API è limitato a 25 (vedere https://developers.google.com/maps/documentation/directions/get-directions per i dettagli). Se abbiamo più di 25 fermate nel nostro itinerario dal LLM, dobbiamo fare più chiamate a Google Maps e quindi unire le risposte
  3. overview_polyline ha una risoluzione limitata quando si fa zoom, probabilmente perché il numero di punti lungo di esso è ottimizzato per una visualizzazione di mappa a grande scala. Questo non è un problema grave per una prova concettuale, ma sarebbe bello avere un controllo più granulare sulla risoluzione del percorso in modo che sia visivamente piacevole anche a zoom elevati. L’API delle indicazioni ci fornisce delle polylinee molto più precise nei segmenti di percorso che fornisce, quindi possiamo farne uso.
  4. Sulla mappa, sarebbe bello suddividere il percorso in segmenti separati e consentire all’utente di visualizzare la distanza e i tempi di viaggio associati a ciascuno di essi. Anche in questo caso, Google Maps ci fornisce queste informazioni, quindi dovremmo farne uso.
La risoluzione dell'overview_polyline è limitata. Qui ci siamo ingranditi su Santa Barbara e non è ovvio quale strada dovremmo prendere.

Il problema 1 può essere facilmente risolto semplicemente modificando build_directions_and_route per utilizzare l’ID del luogo dal mapping_dict anziché l’indirizzo formattato . Il problema 2 è un po’ più complicato e richiede di dividere i nostri punti intermedi in pezzi di una lunghezza massima, creando una parte iniziale, finale e sottolista di punti intermedi da ciascuna e quindi eseguendo build_mapping_dict seguito da build_directions_and_route su quelli. I risultati possono quindi essere concatenati alla fine.

I problemi 3 e 4 possono essere risolti utilizzando le polylinee dei singoli passaggi per ciascuna tratta del percorso restituite da Google Maps. Dobbiamo solo scorrere questi due livelli, decodificare le polylinee pertinenti e quindi costruire un nuovo dizionario. Questo ci consente anche di estrarre i valori di distanza e durata, che vengono assegnati a ciascuna tratta decodificata e quindi utilizzati per la tracciatura.

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

Ora il problema è che la lista leg_route_points può diventare molto lunga e quando cerchiamo di tracciare questo sulla mappa può causare il crash di folium o eseguirsi molto lentamente. La soluzione è campionare i punti lungo il percorso in modo che ce ne siano abbastanza per consentire una buona visualizzazione ma non troppi che la mappa abbia problemi di caricamento.

Un modo semplice e sicuro per farlo è calcolare il numero di punti che il percorso totale dovrebbe contenere (ad esempio 5000 punti), quindi determinare quale frazione dovrebbe appartenere a ciascuna tratta del percorso e quindi campionare uniformemente il numero corrispondente di punti da ciascuna tratta. Nota che dobbiamo assicurarci che ogni tratta contenga almeno un punto per essere inclusa nella mappa.

La seguente funzione eseguirà questo campionamento, prendendo in input un dizionario di waypoints restituito dalla funzione get_route sopra.

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])    # Punti totali nel campione    npoints = int(np.ceil(all_distances / distance_per_point_in_km))        # Punti totali per tratta    punti_per_tratta = [len(v["route"]) for k, v in route.items()]    punti_totali = sum(punti_per_tratta)    # ottenere il numero totale di punti che devono essere rappresentati su ogni tratta    numero_per_tratta = [      max(1, np.round(npoints * (x / punti_totali), 0)) for x in punti_per_tratta      ]    punti_campionati = {}    for id_tratta, info_percorso in route.items():        punti_totali = int(punti_per_tratta[id_tratta])        punti_totali_campionati = int(numero_per_tratta[id_tratta])        step_size = int(max(punti_totali // punti_totali_campionati, 1.0))        percorso_campionato = [                info_percorso["route"][idx] for idx in range(0, punti_totali, step_size)            ]        distanza = info_percorso["distance"]        durata = info_percorso["duration"]        punti_campionati[id_tratta] = {                "route": [                    (float(x.split(",")[0]), float(x.split(",")[1]))                    for x in percorso_campionato                ],                "duration": durata,                "distance": distanza,            }    return punti_campionati

Qui specifica lo spaziamento dei punti che desideriamo – uno ogni 250m – e quindi scegliere il numero di punti di conseguenza. Potremmo anche considerare di implementare un modo per stimare lo spaziamento dei punti desiderato dalla lunghezza del percorso, ma questo metodo sembra funzionare ragionevolmente bene per una prima passata, dando una risoluzione accettabile a livelli moderatamente alti di zoom sulla mappa.

Ora che abbiamo diviso il percorso in segmenti con un numero ragionevole di punti di campionamento, possiamo procedere a tracciarli sulla mappa e etichettare ogni segmento con il seguente codice

for leg_id, route_points in sampled_points.items():    leg_distance = route_points["distance"]    leg_duration = route_points["duration"]    f_group = folium.FeatureGroup("Segmento {}".format(leg_id))    folium.vector_layers.PolyLine(                route_points["route"],                popup="<b>Segmento del percorso {}</b>".format(leg_id),                tooltip="Distanza: {}, Durata: {}".format(leg_distance, leg_duration),                color="blue",                weight=2,    ).add_to(f_group)    # si assume che la mappa sia già stata generata    f_group.add_to(map)
Esempio di un segmento di un percorso che è stato etichettato e annotato in modo da apparire sulla mappa

5. Mettendo tutto insieme

Nel codice sorgente, tutta la metodologia sopra menzionata è confezionata in due classi. La prima è RouteFinder, che prende in ingresso l’output strutturato di Agent (vedi parte 1) e genera il percorso campionato. La seconda è RouteMapper, che prende il percorso campionato e traccia una mappa folium, che può essere salvata come HTML.

Dato che quasi sempre vogliamo generare una mappa quando chiediamo un percorso, il metodo generate_route di RouteFinder gestisce entrambi questi compiti

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("ITINERARIO PROPOSTO")        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("Tempo per costruire il percorso: {}".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("Tempo per generare la mappa: {}".format((round(t2 - t1, 2))))        return directions, sampled_route, mapping_dict

Ricordate che nella parte 1 abbiamo creato una classe chiamata Agent, che gestiva le chiamate LLM. Ora che abbiamo anche RouteFinder, possiamo metterle insieme in una classe base per l’intero progetto di cartografia del viaggio

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        )

Questo può essere eseguito su una query come segue, che è l’esempio fornito nello script 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 = """        Voglio fare un viaggio di 2 settimane da Berkeley CA a New York City.        Voglio visitare parchi nazionali e città con buon cibo.        Voglio usare un'auto a noleggio e guidare per non più di 5 ore al giorno.        """    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)

In termini di generazione di percorsi e mappe, siamo ora finiti! Ma come possiamo impacchettare tutto questo codice in un’interfaccia utente piacevole e facile da sperimentare? Questo sarà trattato nella terza e ultima parte di questa serie.

Grazie per la lettura! Sentiti libero di esplorare l’intera base di codice qui https://github.com/rmartinshort/travel_mapper. Eventuali suggerimenti per il miglioramento o l’estensione della funzionalità saranno molto apprezzati!