florentgbelidji HF staff commited on
Commit
7f4e4c3
·
1 Parent(s): 536d08f

Added source modules of the app

Browse files
Files changed (6) hide show
  1. src/gradio_utils.py +149 -0
  2. src/meteo_france_api.py +72 -0
  3. src/prompts.py +283 -0
  4. src/skitour_api.py +140 -0
  5. src/tools.py +206 -0
  6. src/utils.py +135 -0
src/gradio_utils.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import gradio as gr
3
+ import numpy as np
4
+ import pandas as pd
5
+ from gradio_folium import Folium
6
+ from smolagents.gradio_ui import pull_messages_from_step
7
+ from smolagents.types import handle_agent_output_types, AgentText
8
+ from folium import Map, TileLayer, Marker, Icon, Popup
9
+ from folium.plugins import Fullscreen
10
+
11
+ FINAL_MESSAGE_HEADER = "**Final answer/ Réponse finale** \n 🤖⛷️💭"
12
+
13
+ MAP_URL = "https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png"
14
+
15
+ def toggle_visibility(show):
16
+ return gr.Textbox(visible=show)
17
+
18
+ def create_map_from_markers(dataframe: pd.DataFrame) -> Map:
19
+ """
20
+ Create a Folium map with markers for each location in the dataframe.
21
+ Args:
22
+ dataframe (pd.DataFrame): Dataframe containing the locations.
23
+
24
+ Returns:
25
+ Map: Folium map with markers.
26
+ """
27
+
28
+ f_map = Map(
29
+ location=[dataframe["Latitude"].mean(), dataframe["Longitude"].mean()],
30
+ zoom_start=10,
31
+ tiles=
32
+ TileLayer(
33
+ tiles=MAP_URL,
34
+ attr="Google",
35
+ name="Google Maps",
36
+ overlay=True,
37
+ control=True,
38
+ ),
39
+ )
40
+ for _, row in dataframe.iterrows():
41
+ if np.isnan(row["Latitude"]) or np.isnan(row["Longitude"]):
42
+ continue
43
+ #popup_message = f"<h4 style='color: #d53e2a;'>{row['name'].split(',')[0]}</h4><p style='font-weight:500'>{row['description']}</p>"
44
+ #popup_message += f"<a href='https://www.google.com/search?q={row['name']}' target='_blank'><b>Learn more about {row['name'].split(',')[0]}</b></a>"
45
+
46
+ marker = Marker(
47
+ location=[float(row["Latitude"]), float(row["Longitude"])],
48
+ icon=Icon(color="blue", icon="fa-map-marker", prefix='fa'),
49
+ popup = Popup(f"Infos: <a href='{row['Route Link']}'>{row['Name']}</a>", max_width=300)
50
+ )
51
+ marker.add_to(f_map)
52
+
53
+ Fullscreen(position='topright', title='Expand me', title_cancel='Exit me', force_separate_button=True).add_to(f_map)
54
+
55
+ bounds = [[float(row["Latitude"]), float(row["Longitude"])] for _, row in dataframe.iterrows()]
56
+ f_map.fit_bounds(bounds, padding=(100, 100))
57
+ return f_map
58
+
59
+
60
+ def update_map_on_selection(row: pd.Series, df_routes: gr.State) -> Map:
61
+ """
62
+ Update the map with a marker at the selected location.
63
+ Args:
64
+ row (pd.Series): Selected row from the dataframe.
65
+ Returns:
66
+ Map: Updated Folium map.
67
+ """
68
+ row = df_routes.loc[df_routes['Name'] == row['Name']]
69
+
70
+ f_map = Map(
71
+ location=[row["Latitude"][0], row["Longitude"][0]],
72
+ zoom_start=10,
73
+ tiles=TileLayer(
74
+ tiles=MAP_URL,
75
+ attr="Google",
76
+ name="Google Maps",
77
+ overlay=True,
78
+ control=True,
79
+ ),
80
+ )
81
+ popup = Popup(f"Infos: <a href='{row['Route Link'][0]}'>{row['Name'][0]}</a>", max_width=300)
82
+ Marker(
83
+ location=[row["Latitude"][0], row["Longitude"][0]],
84
+ icon=Icon(color="blue", icon="fa-map-marker", prefix='fa'),
85
+ popup=popup
86
+ ).add_to(f_map)
87
+
88
+ Fullscreen(position='topright', title='Expand', title_cancel='Exit', force_separate_button=True).add_to(f_map)
89
+
90
+ return f_map
91
+
92
+
93
+ # Simplified interaction function
94
+ def interact_with_agent(agent, prompt, messages, df_routes, additional_args):
95
+
96
+ messages.append(gr.ChatMessage(role="user", content=prompt))
97
+ yield (messages, df_routes, gr.Textbox(value=FINAL_MESSAGE_HEADER, container=True))
98
+
99
+ for msg, _df_routes, final_message in stream_to_gradio(
100
+ agent,
101
+ df_routes=df_routes,
102
+ task=prompt,
103
+ reset_agent_memory=True,
104
+ additional_args=additional_args,
105
+ ):
106
+ messages.append(msg)
107
+ yield (messages, _df_routes, final_message)
108
+
109
+ yield (messages, _df_routes, final_message)
110
+
111
+
112
+ def stream_to_gradio(
113
+ agent,
114
+ df_routes,
115
+ task: str,
116
+ test_mode: bool = False,
117
+ reset_agent_memory: bool = False,
118
+ **kwargs,
119
+ ):
120
+ """Runs an agent with the given task and streams the messages from the agent as gradio ChatMessages."""
121
+
122
+ for step_log in agent.run(task, stream=True, reset=reset_agent_memory, **kwargs):
123
+ for message in pull_messages_from_step(step_log, test_mode=test_mode):
124
+ yield (message, df_routes, gr.Markdown(value=FINAL_MESSAGE_HEADER , container=True))
125
+
126
+ final_answer = step_log # Last log is the run's final_answer
127
+ final_answer = handle_agent_output_types(final_answer)
128
+ if isinstance(final_answer, dict):
129
+ final_message = final_answer.get("message")
130
+ itineraries = final_answer.get("itineraries")
131
+ if itineraries:
132
+ print("HEEERE")
133
+ df_routes = pd.DataFrame(itineraries)
134
+ df_routes.columns = ["id", "Name", "Latitude", "Longitude", "Route Link"]
135
+ print(df_routes)
136
+
137
+ else:
138
+ final_message = final_answer
139
+
140
+ text_output = gr.Markdown(value=FINAL_MESSAGE_HEADER + f": {str(final_message)}", container=True)
141
+ if isinstance(final_answer, AgentText):
142
+ yield (gr.ChatMessage(
143
+ role="assistant",
144
+ content=f"**Final answer:**\n{str(final_message)}\n",
145
+ ), df_routes, text_output)
146
+
147
+ else:
148
+ yield (gr.ChatMessage(role="assistant", content=str(final_message)), df_routes, text_output)
149
+
src/meteo_france_api.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import requests
4
+ import xml.etree.ElementTree as ET
5
+ from typing import List, Dict
6
+ from meteofrance_api import MeteoFranceClient
7
+
8
+ METEOFRANCE_API_URL = 'https://public-api.meteofrance.fr/public/DPBRA/v1/'
9
+ METEO_FRANCE_TOKEN = os.getenv('METEO_FRANCE_API_TOKEN')
10
+
11
+ def get_massifs_meteo_france() -> List[Dict]:
12
+ """
13
+ Fetch the list of massifs from Meteo France API.
14
+
15
+ Returns:
16
+ List[Dict]: List of massifs with their details.
17
+ """
18
+ url = METEOFRANCE_API_URL + 'liste-massifs'
19
+ headers = {'apikey': METEO_FRANCE_TOKEN, 'accept': '*/*'}
20
+ response = requests.get(url, headers=headers)
21
+ response = response.json()
22
+ liste_massifs = []
23
+ for massif in response['features']:
24
+ liste_massifs.append({
25
+ "id": massif['properties']['code'],
26
+ "nom": massif['properties']['title'],
27
+ "groupe": massif['properties']['Departemen'],
28
+ })
29
+ return liste_massifs
30
+
31
+ def extraire_texte(element: ET.Element) -> str:
32
+ """
33
+ Extract all text from an XML element recursively.
34
+
35
+ Args:
36
+ element (ET.Element): XML element.
37
+
38
+ Returns:
39
+ str: Extracted text.
40
+ """
41
+ texte = element.text or ""
42
+ for enfant in element:
43
+ texte += extraire_texte(enfant)
44
+ texte += element.tail or ""
45
+ return texte
46
+
47
+ def get_massif_conditions(massif_id: str) -> str:
48
+ """
49
+ Fetch the weather conditions for a given massif.
50
+
51
+ Args:
52
+ massif_id (str): ID of the massif.
53
+
54
+ Returns:
55
+ str: Weather conditions in plain text.
56
+ """
57
+ url = METEOFRANCE_API_URL + 'massif/BRA'
58
+ headers = {'apikey': METEO_FRANCE_TOKEN, 'accept': '*/*'}
59
+ params = {'id-massif': massif_id, "format": "xml"}
60
+ response = requests.get(url, headers=headers, params=params)
61
+ xml_text = response.text
62
+ root = ET.fromstring(xml_text)
63
+ text = extraire_texte(root)
64
+ #remove file names
65
+ text = re.sub(r'\b[\w\-]+\.[a-zA-Z0-9]+\b', '', text).strip()
66
+ return text
67
+
68
+ def get_forecast(latitude, longitude):
69
+
70
+ client = MeteoFranceClient(METEO_FRANCE_TOKEN)
71
+ forecast = client.get_forecast(latitude, longitude)
72
+
src/prompts.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GRADING_SUMMARY = """
2
+ Ski descent grading evaluates slope difficulty (1–5), with levels 1–4 subdivided (e.g., 2.1, 4.2). Key details:
3
+ - Ski 1: Beginner, ≤30° slope, wide passages, minimal exposure.
4
+ - Ski 2: Moderate, ≤35° slope, may have high exposure/elevation.
5
+ - Ski 3: Ski mountaineering, technical sections, ≤40°/45° slopes.
6
+ - Ski 4: Advanced, sustained 40°–45° slopes, narrow/dense terrain.
7
+ - Ski 5: Extreme, 45°–50°+ slopes, long/exposed terrain.
8
+
9
+ Ascent grading aligns with alpine classifications (R, F, PD, AD, D):
10
+ - R: Easy hiking/snowshoeing, Ski 1–2.3.
11
+ - F: Easy mountaineering, Ski 2.1–3.3.
12
+ - PD: Moderate, Ski 3.3–4.3.
13
+ - AD: Difficult, Ski 5.1–5.3.
14
+ - D: Very difficult, Ski 5.3+.
15
+
16
+ Exposure grading (E1–E4) evaluates fall risk:
17
+ - E1: Minor obstacles; injury risk from steep slopes.
18
+ - E2: Added risk from cliffs or tight couloirs.
19
+ - E3: Likely fatal falls (cliffs, collisions).
20
+ - E4: Guaranteed fatal falls (sheer drops, severe impacts).
21
+ """
22
+
23
+
24
+ LANGUAGE = """Tu dois toujours répondre en français"""
25
+
26
+ FLOW_OF_THOUGHTS = """
27
+
28
+ When asked about ski touring itineraries, you should:
29
+ - Find the mountain range closest to the location the user is interested in.
30
+ - Fetch the list of itineraries for the mountain range.
31
+ - Filter the itineraries based on the user's preferences (e.g., difficulty, elevation gain, etc.).
32
+ - Fetch specific details about selected itineraries (e.g., conditions, topo, etc.) with topo_info tool.
33
+ - Interpret weather and avalanche conditions and assess the safety of the itinerary.
34
+ - If it's not safe, suggest an alternative itinerary or advise against going.
35
+ - Provide a clear and concise response to the user's query with all the necessary information about the itinerary and conditions.
36
+
37
+ """
38
+
39
+ # OUTPUT_FORMAT = """
40
+
41
+ # The output should be a dictionary with the following structure:
42
+ # {
43
+ # "message": str (message to the user),
44
+ # "itineraries": Union[List[Dict], None] (list of itineraries with details)
45
+ # [{
46
+ # "topo_id": str (id du topo),
47
+ # "topo_name": str (nom du topo),
48
+ # "topo_lat": float (latitude du topo),
49
+ # "topo_lon": float (longitude du topo),
50
+ # "topo_link": str (link to the topo),
51
+ # "description": str (description détaillée du topo),
52
+ # "avalanche_conditions": str (summary of the avalanche and snow conditions in `avalanche_conditions` field),
53
+ # "weather_conditions": str (summary of the weather forecast in `weather_conditions` field),
54
+ # }]
55
+ # }
56
+ # `message` should include a user-friendly response that addresses the user's query and provides all the relevant information about the itineraries and conditions when needed and explain why they were selected.
57
+ # `itineraries` should contain a list of dictionaries, each representing an itinerary with its details. If no itineraries are found or the user query is not about finding itineraries, `itineraries` should be `None`.
58
+ # `itineraries`is just for back-end purposes, ther user will not see it. The user will only see the `message` so make sure to include all the relevant information in the `message` field and complete details in the `itineraries` field.
59
+ # """
60
+
61
+ OUTPUT_FORMAT = """
62
+
63
+ The output should be a dictionary with the following structure:
64
+ {{
65
+ "message": str (message to the user),
66
+ "itineraries": Union[List[str], None] (list of itineraries with details)
67
+ [{{
68
+ "topo_id": str (id du topo),
69
+ "topo_link": str (link to the topo),
70
+ }}]
71
+ }}
72
+ `message` should include a user-friendly response that addresses the user's query and provides all the relevant information about the itineraries and conditions when needed and explain why they were selected.
73
+ `itineraries` should contain a list of dictionaries, each representing an itinerary with its details. If no itineraries are found or the user query is not about finding itineraries, `itineraries` should be `None`.
74
+ `itineraries`is just for back-end purposes, ther user will not see it. The user will only see the `message` so make sure to include all the relevant information in the `message` field and complete details in the `itineraries` field.
75
+ """
76
+
77
+ IMPORTANT = """
78
+ It's crucial to consider avalanche and weather conditions when suggesting ski touring itineraries.
79
+ If weather or avalanches conditions are bad always warn the user.
80
+ Always prioritize user safety. Don't hesitate to recommend alternative itineraries or suggest waiting for better conditions.
81
+ """
82
+
83
+
84
+ SKI_TOURING_ASSISTANT_PROMPT = """
85
+
86
+ You're an agent as defined earlier. You're assigned a specific task: assist ski practionners in {language} by providing expert recommendations on ski touring itineraries, with a focus on safety, relevant grading systems, and user preferences.
87
+ You'll use the available APIs and tools to fetch information about ski touring itineraries, weather forecasts, avalanche risks and conditions, mountain huts access, and more. You'll have to analyze carefully those data to provide the best recommendations to the users.
88
+ Don't forget that users can also ask random questions that you should be able to answer as best as you can not necessarily related to ski touring itineraries.
89
+
90
+
91
+ ABOUT GRADING SYSTEMS
92
+ Ski Difficulty (1–5):
93
+
94
+ Ski 1: Beginner – ≤30° slopes, wide passages, minimal exposure.
95
+ Ski 2: Moderate – ≤35° slopes, may involve high exposure/elevation.
96
+ Ski 3: Ski mountaineering – technical sections, ≤40°–45° slopes.
97
+ Ski 4: Advanced – sustained 40°–45° slopes, narrow/dense terrain.
98
+ Ski 5: Extreme – ≥45° slopes, long and exposed terrain.
99
+ Ascent Grading (R, F, PD, AD, D):
100
+
101
+ R: Easy hiking/snowshoeing – Ski 1–2.3.
102
+ F: Easy mountaineering – Ski 2.1–3.3.
103
+ PD: Moderate mountaineering – Ski 3.3–4.3.
104
+ AD: Difficult mountaineering – Ski 5.1–5.3.
105
+ D: Very difficult – Ski 5.3+.
106
+ Exposure Grading (E1–E4):
107
+
108
+ E1: Minor obstacles – injury risk from steep slopes.
109
+ E2: Additional risks (e.g., cliffs or tight couloirs).
110
+ E3: Likely fatal falls (cliffs, collisions).
111
+ E4: Guaranteed fatal falls (sheer drops, severe impacts).
112
+
113
+ GUIDELINES FOR RESPONSES
114
+
115
+ Important!!!
116
+ Language: Always respond in {language}.
117
+
118
+
119
+ OUTPUT FORMAT
120
+
121
+ {{
122
+ "message": "str (detailed message for the user in {language})",
123
+ "itineraries": [
124
+ {{
125
+ "topo_id": "str (ID of the itinerary from the API)",
126
+ "name": "str (name of the itinerary)",
127
+ "topo_start_lat": "float (latitude of the start of itinerary)",
128
+ "topo_start_lon": "float (longitude of the start itinerary)",
129
+ "topo_link": "str (link to the itinerary taken from the API)"
130
+ }}
131
+ ]
132
+ }}
133
+
134
+ The message should be user-friendly, detailed, and include all relevant information.
135
+ The itineraries field is for backend use only and contains structured data about the itineraries. If no itineraries match or the query isn't about finding itineraries, set itineraries to None.
136
+
137
+ EXAMPLES
138
+
139
+ {{"question": Que proposes tu comme itinéraire de ski de randonnée vers Briançon?}}
140
+ {{'message': "Je vous recommande l'itinéraire 'Col de l'Aiguillette, Aller Retour depuis le pont
141
+ de l'Alpe', situé dans le massif des Cerces - Thabor - Mont Cenis. Ce parcours est d'une difficulté de ski de 2.2
142
+ et d'une exposition de 1, adapté pour les skieurs ayant un certain niveau de maîtrise. L'ascension est classée R,
143
+ facilement accessible, avec un gain d'élévation de 850 mètres.\n\n**Conditions Avalanches:**\nLes conditions
144
+ actuelles montrent un risque marqué d'avalanches en raison de nombreuses plaques friables et plus dures. Les
145
+ avalanches spontanées ainsi que celles déclenchées par les skieurs sont anticipées, un risque à considérer
146
+ sérieusement avant de poursuivre cet itinéraire.\n\n**Prévisions Météo:**\nLe 8 janvier, un léger enneigement est
147
+ prévu, avant de se transformer en pluie dans la matinée. Les températures commencent à refroidir et l'humidité est
148
+ élevée. Il est recommandé de bien se préparer pour ces conditions durant vos activités outdoor.\n\nPour votre
149
+ sécurité, évaluez toujours les conditions météo et avalanches directement sur le terrain avant de vous lancer dans
150
+ l'itinéraire. Bonne randonnée et restez prudent.", 'itineraries': [{{'topo_id': '104', 'name':Col de l'Aiguillette, Aller Retour depuis le pont
151
+ de l'Alpe ,'topo_link':
152
+ 'https://skitour.fr/topos/104', 'topo_start_lat': 45.0181, 'topo_start_lon': 6.4663}}]}}
153
+
154
+ {{"question": Quelles sont les conditions météo pour le massif du Mont-Blanc ?}}
155
+ {{'message': "Les conditions météorologiques pour le massif du Mont-Blanc du 7 au 8 janvier 2025
156
+ indiquent des températures froides avec des vents de vitesse variable et un ciel couvert, accompagnés de périodes
157
+ de chutes de neige. Points clés :\n\n- **Températures** : Commençant à -19,3°C ressentis -30,5°C le 7 janvier à
158
+ 16h, elles augmentent graduellement la nuit à environ -7°C à -8,6°C ressentis -11,4°C à -14,9°C le lendemain
159
+ après-midi.\n\n- **Vent** : Principalement de l'ouest-nord-ouest (260° à 300°) avec des vitesses de 6 à 10 km/h et
160
+ rafales jusqu'à 22 km/h, plus fortes pendant les chutes de neige.\n\n- **Conditions Météo** : Alternance de petites
161
+ éclaircies et ciel partiellement nuageux (30% de couverture), se chargeant fortement (90%) à partir de 20h avec des
162
+ épisodes de neige durant la nuit et au petit matin suivant.\n\n- **Précipitations** : Pas de pluie ; début de
163
+ légères chutes de neige à 21h avec accumulation augmentant pour atteindre jusqu'à 4,7 mm/heure durant la nuit du 8
164
+ janvier. La neige s'atténue ensuite.\n\n- **Humidité** : De 45% le soir à 100% la nuit, restant élevée durant les
165
+ chutes de neige.\n\n- **Pression au niveau de la mer** : Fluctuations légères, autour de 1010-1015 hPa, indiquant
166
+ des conditions stables avec un abaissement de pression la nuit.\n\n- **Limite Pluie-Neige et Niveau Iso:0** : Non
167
+ pertinent initialement, mais augmentant à 1550m à 2550m, suggérant des masses d'air chaud en
168
+ altitude.\n\n\nConcernant les avalanches, le bulletin du 8 janvier 2025 met en garde contre de nombreuses plaques
169
+ sensibles dans la neige fraîche, avec un risque marqué. Des avalanches peuvent se produire spontanément jusqu'à la
170
+ mi-journée au-dessus de 1800 m, et peuvent être facilement déclenchées par les skieurs au-dessus de 2000 m. Les
171
+ plaques vont de petite à moyenne taille (Taille 1 à 2), bien que des plaques plus grandes puissent se former
172
+ au-dessus de 2600/3000 m.\n\nPour votre sécurité, tenez compte des prévisions de précipitations ventées dès
173
+ mercredi soir. La neige est lourde et mouillée sous 1600 m, affectée par les pluies. L'enneigement reste moyen pour
174
+ la saison au-dessus de 2000 m.\n\nPour des mises à jour de vigilance, veuillez consulter les sites dédiés.",
175
+ 'itineraries': None}}
176
+
177
+ {{"question": Hello how are you?}}
178
+ {{'message': "I'm doing great and you, how can I help you today?", 'itineraries': None}}
179
+
180
+ SAFETY AND PRIORITIZATION
181
+ Avalanche and Weather Conditions:
182
+
183
+ Always assess and communicate the risks of avalanche and adverse weather conditions.
184
+ Warn users if conditions are unsafe and suggest waiting for better conditions.
185
+ Safety First:
186
+
187
+ Prioritize user safety in all recommendations.
188
+ Recommend alternatives or explicitly advise against outings if necessary.
189
+
190
+ """
191
+
192
+ SKI_TOURING_ASSISTANT_PROMPT = """
193
+
194
+ ROLE
195
+ You are an expert assistant specializing in ski touring. Your primary task is to assist users in {language} by providing tailored recommendations for ski touring itineraries, focusing on safety, grading systems, and user preferences.
196
+
197
+ TASK REQUIREMENTS
198
+
199
+ Use APIs and tools to gather data on:
200
+ Ski touring itineraries
201
+ Weather forecasts
202
+ Avalanche risks and conditions
203
+ Mountain hut access
204
+ Analyze the data and deliver user-friendly, detailed recommendations.
205
+ Always interogate yourself about the routes access, snow and weather conditions before suggesting them to the users. `describe_route` tool will be useful for that. It's the most important part of your job.
206
+ Answer general queries unrelated to ski touring to the best of your ability.
207
+
208
+ GRADING SYSTEMS
209
+
210
+ Ski Difficulty (1–5): Define skill levels based on slope gradient, exposure, and terrain.
211
+ Ascent Grading (R, F, PD, AD, D): Classify ascents based on technical challenges.
212
+ Exposure Grading (E1–E4): Highlight the severity of risks, from minor obstacles to life-threatening falls.
213
+ RESPONSE FORMAT
214
+
215
+ json
216
+ Copy code
217
+ {{
218
+ "message": "string (a detailed, user-friendly response in {language})",
219
+ "itineraries": [
220
+ {{
221
+ "topo_id": "string (ID of the itinerary from the API)",
222
+ "name": "string (name of the itinerary)",
223
+ "topo_start_lat": "float (latitude of itinerary start)",
224
+ "topo_start_lon": "float (longitude of itinerary start)",
225
+ "topo_link": "string (link to the itinerary from the API)"
226
+ }}
227
+ ])
228
+ }}
229
+ Use the message field to explain recommendations and safety precautions clearly.
230
+ If no itineraries match or the query isn’t itinerary-related, set itineraries to None.
231
+ GUIDELINES
232
+
233
+ Always prioritize user safety. Warn users of unsafe conditions and suggest alternatives.
234
+ Respond only in {language}.
235
+ Structure messages to include relevant information such as:
236
+ Difficulty, ascent, and exposure grading.
237
+ Weather and avalanche conditions.
238
+ Recommendations for preparation and safety.
239
+
240
+ EXAMPLES
241
+
242
+ {{"question": Que proposes tu comme itinéraire de ski de randonnée vers Briançon?}}
243
+ {{'message': "Je vous recommande l'itinéraire 'Col de l'Aiguillette, Aller Retour depuis le pont
244
+ de l'Alpe', situé dans le massif des Cerces - Thabor - Mont Cenis. Ce parcours est d'une difficulté de ski de 2.2
245
+ et d'une exposition de 1, adapté pour les skieurs ayant un certain niveau de maîtrise. L'ascension est classée R,
246
+ facilement accessible, avec un gain d'élévation de 850 mètres.\n\n**Conditions Avalanches:**\nLes conditions
247
+ actuelles montrent un risque marqué d'avalanches en raison de nombreuses plaques friables et plus dures. Les
248
+ avalanches spontanées ainsi que celles déclenchées par les skieurs sont anticipées, un risque à considérer
249
+ sérieusement avant de poursuivre cet itinéraire.\n\n**Prévisions Météo:**\nLe 8 janvier, un léger enneigement est
250
+ prévu, avant de se transformer en pluie dans la matinée. Les températures commencent à refroidir et l'humidité est
251
+ élevée. Il est recommandé de bien se préparer pour ces conditions durant vos activités outdoor.\n\nPour votre
252
+ sécurité, évaluez toujours les conditions météo et avalanches directement sur le terrain avant de vous lancer dans
253
+ l'itinéraire. Bonne randonnée et restez prudent.", 'itineraries': [{{'topo_id': '104', 'name':Col de l'Aiguillette, Aller Retour depuis le pont
254
+ de l'Alpe ,'topo_link':
255
+ 'https://skitour.fr/topos/104', 'topo_start_lat': 45.0181, 'topo_start_lon': 6.4663}}]}}
256
+
257
+ {{"question": Quelles sont les conditions météo pour le massif du Mont-Blanc ?}}
258
+ {{'message': "Les conditions météorologiques pour le massif du Mont-Blanc du 7 au 8 janvier 2025
259
+ indiquent des températures froides avec des vents de vitesse variable et un ciel couvert, accompagnés de périodes
260
+ de chutes de neige. Points clés :\n\n- **Températures** : Commençant à -19,3°C ressentis -30,5°C le 7 janvier à
261
+ 16h, elles augmentent graduellement la nuit à environ -7°C à -8,6°C ressentis -11,4°C à -14,9°C le lendemain
262
+ après-midi.\n\n- **Vent** : Principalement de l'ouest-nord-ouest (260° à 300°) avec des vitesses de 6 à 10 km/h et
263
+ rafales jusqu'à 22 km/h, plus fortes pendant les chutes de neige.\n\n- **Conditions Météo** : Alternance de petites
264
+ éclaircies et ciel partiellement nuageux (30% de couverture), se chargeant fortement (90%) à partir de 20h avec des
265
+ épisodes de neige durant la nuit et au petit matin suivant.\n\n- **Précipitations** : Pas de pluie ; début de
266
+ légères chutes de neige à 21h avec accumulation augmentant pour atteindre jusqu'à 4,7 mm/heure durant la nuit du 8
267
+ janvier. La neige s'atténue ensuite.\n\n- **Humidité** : De 45% le soir à 100% la nuit, restant élevée durant les
268
+ chutes de neige.\n\n- **Pression au niveau de la mer** : Fluctuations légères, autour de 1010-1015 hPa, indiquant
269
+ des conditions stables avec un abaissement de pression la nuit.\n\n- **Limite Pluie-Neige et Niveau Iso:0** : Non
270
+ pertinent initialement, mais augmentant à 1550m à 2550m, suggérant des masses d'air chaud en
271
+ altitude.\n\n\nConcernant les avalanches, le bulletin du 8 janvier 2025 met en garde contre de nombreuses plaques
272
+ sensibles dans la neige fraîche, avec un risque marqué. Des avalanches peuvent se produire spontanément jusqu'à la
273
+ mi-journée au-dessus de 1800 m, et peuvent être facilement déclenchées par les skieurs au-dessus de 2000 m. Les
274
+ plaques vont de petite à moyenne taille (Taille 1 à 2), bien que des plaques plus grandes puissent se former
275
+ au-dessus de 2600/3000 m.\n\nPour votre sécurité, tenez compte des prévisions de précipitations ventées dès
276
+ mercredi soir. La neige est lourde et mouillée sous 1600 m, affectée par les pluies. L'enneigement reste moyen pour
277
+ la saison au-dessus de 2000 m.\n\nPour des mises à jour de vigilance, veuillez consulter les sites dédiés.",
278
+ 'itineraries': None}}
279
+
280
+ {{"question": Hello how are you?}}
281
+ {{'message': "I'm doing great and you, how can I help you today?", 'itineraries': None}}
282
+
283
+ """
src/skitour_api.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+ import os
4
+ import datetime
5
+ from typing import List, Dict
6
+
7
+ SKITOUR_API_URL = 'https://skitour.fr/api/'
8
+
9
+ def get_massifs() -> List[Dict]:
10
+ """
11
+ Fetch the list of massifs from the Skitour API.
12
+
13
+ Returns:
14
+ List[Dict]: List of massifs with their details.
15
+ """
16
+ url = SKITOUR_API_URL + 'massifs'
17
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
18
+ response = requests.get(url, headers=headers, timeout=10)
19
+ return response.json()
20
+
21
+ def get_topos(ids_massif: str) -> List[Dict]:
22
+ """
23
+ Fetch ski touring itineraries for a given massif.
24
+
25
+ Args:
26
+ ids_massif (str): ID of the massif.
27
+
28
+ Returns:
29
+ List[Dict]: List of itineraries for the specified massif.
30
+ """
31
+ url = SKITOUR_API_URL + 'topos'
32
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
33
+ params = {'m': ids_massif}
34
+ response = requests.get(url, headers=headers, params=params, timeout=10)
35
+ return json.loads(response.text.replace('\\\\', '\\'))
36
+
37
+ def get_sommets(massif_id: str) -> List[Dict]:
38
+ """
39
+ Fetch the list of summits for a given massif.
40
+
41
+ Args:
42
+ massif_id (str): ID of the massif.
43
+
44
+ Returns:
45
+ List[Dict]: List of summits with their details.
46
+ """
47
+ url = SKITOUR_API_URL + 'sommets'
48
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
49
+ params = {'m': massif_id}
50
+ response = requests.get(url, headers=headers, params=params)
51
+ response = response.json()
52
+ sommets = []
53
+ for _sommets in response:
54
+ sommets.append({
55
+ "name": _sommets['sommet'],
56
+ "lat": float(_sommets['latlon'][0]),
57
+ "lon": float(_sommets['latlon'][1]),
58
+ "range": _sommets['massif']['nom']
59
+ })
60
+ return sommets
61
+
62
+ def get_refuges(massif_ids: str) -> List[Dict]:
63
+ """
64
+ Fetch the list of refuges for a given massif.
65
+
66
+ Args:
67
+ massif_ids (str): ID(s) of the massif(s).
68
+
69
+ Returns:
70
+ List[Dict]: List of refuges.
71
+ """
72
+ url = SKITOUR_API_URL + 'refuges'
73
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
74
+ params = {'m': massif_ids}
75
+ response = requests.get(url, headers=headers, params=params, timeout=10)
76
+ return response.json()
77
+
78
+ def get_details_topo(id_topo):
79
+ url = SKITOUR_API_URL + f'topo/{id_topo}'
80
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
81
+ response = requests.get(url, headers=headers)
82
+ return response.json()
83
+
84
+ def get_conditions(massif_ids: str) -> List[Dict]:
85
+ """
86
+ Fetch the list of refuges for a given massif.
87
+
88
+ Args:
89
+ massif_ids (str): ID(s) of the massif(s).
90
+
91
+ Returns:
92
+ List[Dict]: List of refuges.
93
+ """
94
+ url = SKITOUR_API_URL + 'refuges'
95
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
96
+ params = {'m': massif_ids}
97
+ response = requests.get(url, headers=headers, params=params, timeout=10)
98
+ return response.json()
99
+
100
+ def get_outing(id_outing: str) -> Dict:
101
+ """
102
+ Fetch the details of a specific outing.
103
+
104
+ Args:
105
+ id_outing (str): ID of the outing.
106
+
107
+ Returns:
108
+ Dict: Details of the outing.
109
+ """
110
+ url = SKITOUR_API_URL + f'sortie/{id_outing}'
111
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
112
+ response = requests.get(url, headers=headers, timeout=10)
113
+ return response.json()
114
+
115
+ def get_recent_outings(massif_id: str) -> List[Dict]:
116
+ """
117
+ Fetch the list of recent outings for a given massif.
118
+
119
+ Args:
120
+ massif_id (str): ID of the massif.
121
+
122
+ Returns:
123
+ List[Dict]: List of recent outings.
124
+ """
125
+ url = SKITOUR_API_URL + 'sorties'
126
+ headers = {'cle': os.getenv('SKITOUR_API_TOKEN')}
127
+ params = {'m': massif_id, 'j':30}
128
+ response = requests.get(url, headers=headers, params=params, timeout=10)
129
+ response = response.json()
130
+ if response:
131
+
132
+ for _response in response:
133
+ _response['date'] = datetime.datetime.fromtimestamp(float(_response['date'])).strftime('%Y-%m-%d')
134
+ _response['description'] = get_outing(_response['id'])
135
+ return response
136
+ else:
137
+ return []
138
+
139
+
140
+
src/tools.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pandas as pd
3
+ from smolagents import Tool
4
+ from typing import List, Dict, Any, Union, Tuple
5
+ from meteofrance_api import MeteoFranceClient
6
+ from src.skitour_api import get_topos, get_refuges, get_details_topo, get_massifs, get_recent_outings
7
+ from src.meteo_france_api import get_massif_conditions
8
+ from src.utils import geocode_location, assign_location_to_clusters, haversine, llm_summarizer
9
+
10
+
11
+
12
+ class RefugeTool(Tool):
13
+ name = "refuge_recherche"
14
+ description = "Recherche d'un refuge dans un massif donné"
15
+
16
+ inputs = {
17
+ "massif_id": {
18
+ "description": "[Optional, default: None] Id du massif souhaité ",
19
+ "type": "string",
20
+ }
21
+ }
22
+ output_type = "string"
23
+
24
+ def forward(self, massif_id) -> List[Dict]:
25
+ return get_refuges(massif_id)
26
+
27
+
28
+ class GetRoutesTool(Tool):
29
+ name = "list_routes"
30
+ description = """
31
+ Looking for a list of ski touring routes in a given list of mountain ranges.
32
+ Returns a list containing the information of the topos found.
33
+ Use `topo_details` immediately after this tool to get the details of a specific topo.
34
+ """
35
+
36
+ inputs = {
37
+ "mountain_range_ids": {
38
+ "description": "List of mountain range ids",
39
+ "type": "string",
40
+ }
41
+ }
42
+ output_type = "any"
43
+
44
+ def forward(self, mountain_range_ids: str) -> List[Dict]:
45
+
46
+ topos = get_topos(mountain_range_ids)
47
+
48
+ return topos
49
+
50
+ class DescribeRouteTool(Tool):
51
+ name = "describe_route"
52
+ description = """
53
+ Searches for key information about a specific ski touring route, including weather forecasts and associated avalanche risks.
54
+ Always use this tool after using the `list_routes` tool.
55
+ This tool returns a dictionary containing the route's information, the avalanche risk estimation bulletin, and the weather forecast for the coming days of the route.
56
+ """
57
+ inputs = {
58
+ "id_route": {
59
+ "description": "id of the route",
60
+ "type": "string",
61
+ },
62
+ "id_range": {
63
+ "description": "mountain range id of the route",
64
+ "type": "string"}
65
+ }
66
+ output_type = "any"
67
+
68
+ def __init__(self, skitour2meteofrance: dict, llm_engine: Any):
69
+ super().__init__()
70
+ self.massifs_infos = skitour2meteofrance
71
+ self.weather_client = MeteoFranceClient(access_token=os.getenv("METEO_FRANCE_API_KEY"))
72
+ self.llm_engine = llm_engine
73
+
74
+ def forward(self, id_route: str, id_range: str) -> dict:
75
+
76
+ topo_info = get_details_topo(str(id_route))
77
+ avalanche_conditions = get_massif_conditions(
78
+ self.massifs_infos[str(id_range)]['meteofrance_id']
79
+ )
80
+ lat, lon = topo_info["depart"]["latlon"]
81
+ weather_forecast = self.weather_client.get_forecast(float(lat), float(lon))
82
+ daily_forecast = weather_forecast.forecast[:24]
83
+
84
+ for day_forecast in daily_forecast:
85
+ day_forecast["dt"] = weather_forecast.timestamp_to_locale_time(day_forecast["dt"]).isoformat()
86
+ forecast_summary = llm_summarizer(str(daily_forecast), self.llm_engine)
87
+ avalanche_summary = llm_summarizer(str(avalanche_conditions), self.llm_engine)
88
+ return {
89
+ "route_info": topo_info,
90
+ "avalanche_conditions": avalanche_summary,
91
+ "daily_weather_forecast": forecast_summary,
92
+ "route_link": f"https://skitour.fr/topos/{id_route}"
93
+ }
94
+
95
+ class RecentOutingsTool(Tool):
96
+ name = "recent_outings"
97
+ description = """
98
+ Searches for recent outings in a given mountain range.
99
+ Returns a list of the most recent outings in the given range.
100
+ """
101
+ inputs = {
102
+ "id_range": {
103
+ "description": "id of the mountain range",
104
+ "type": "string",
105
+ }
106
+ }
107
+ output_type = "any"
108
+
109
+ def forward(self, id_range: str) -> List[Dict]:
110
+ return get_recent_outings(id_range)
111
+
112
+ class MountainRangesTool(Tool):
113
+ name = "list_mountain_ranges"
114
+ description = """ Searches for the ID(s) of the mountain ranges closest to a given location.
115
+ If the location is too far from known ranges, the search returns None.
116
+ Should return a string with the massif IDs separated by commas.
117
+ """
118
+
119
+ inputs = {
120
+ "location": {
121
+ "description": "Location to search for",
122
+ "type": "string",
123
+ },
124
+
125
+ "num_ranges": {
126
+ "description": "[Optional, default: 3] Number of closest mountain ranges to return",
127
+ "type": "number",
128
+ }
129
+ }
130
+ output_type = "string"
131
+
132
+ def __init__(self, clusters: Dict[str, List[Tuple[float, float]]]):
133
+ super().__init__()
134
+ self.clusters = clusters
135
+
136
+ def forward(self, location: str, num_ranges: int) -> Union[str, None]:
137
+
138
+ coord_location = geocode_location(location)
139
+ if not location:
140
+ return None
141
+
142
+ matched_ranges = assign_location_to_clusters(coord_location, self.clusters, k=num_ranges)
143
+
144
+
145
+ list_ranges = [range[0] for range in matched_ranges if range[1] < 100]
146
+ if not list_ranges:
147
+ return ''
148
+
149
+ massifs= get_massifs()
150
+
151
+ massif_ids = [_massif['id'] for _massif in massifs if _massif['nom'] in list_ranges]
152
+ return ", ".join(massif_ids)
153
+
154
+ class ForecastTool(Tool):
155
+ name = "forecast"
156
+ description = """Searches for the weather forecast for a given location as well as the current avalanche risk estimation bulletin.
157
+ Unnecessary if the user is inquiring about a route, as `describe_route` already provides this information."""
158
+
159
+ inputs = {
160
+ "location": {
161
+ "description": "Location to search for",
162
+ "type": "string",
163
+ },
164
+ }
165
+
166
+ output_type = "any"
167
+
168
+ def __init__(self, llm_engine, clusters: Dict[str, List[Tuple[float, float]]], skitour2meteofrance: dict):
169
+ super().__init__()
170
+ self.clusters = clusters
171
+ self.massifs_infos = skitour2meteofrance
172
+ self.llm_engine = llm_engine
173
+
174
+ def forward(self, location: str) -> Union[Dict[str, Any], None]:
175
+
176
+ coord_location = geocode_location(location)
177
+ if not location:
178
+ return None
179
+
180
+ # Get the closest mountain range to the location to get the avalanche conditions
181
+ matched_ranges = assign_location_to_clusters(coord_location, self.clusters, k=1)
182
+
183
+ list_ranges = [range[0] for range in matched_ranges if range[1] < 100]
184
+ if not list_ranges:
185
+ return None
186
+
187
+ massifs= get_massifs()
188
+
189
+ massif_id = [_massif['id'] for _massif in massifs if _massif['nom'] in list_ranges]
190
+
191
+ avalanche_conditions = get_massif_conditions(
192
+ self.massifs_infos[str(massif_id[0])]['meteofrance_id']
193
+ )
194
+
195
+ weather_client = MeteoFranceClient(access_token=os.getenv("METEO_FRANCE_API_KEY"))
196
+ forecast = weather_client.get_forecast(*coord_location)
197
+ daily_forecast = forecast.forecast[:24]
198
+
199
+ for day_forecast in daily_forecast:
200
+ day_forecast["dt"] = forecast.timestamp_to_locale_time(day_forecast["dt"]).isoformat()
201
+
202
+ forecast_summary = llm_summarizer(str(daily_forecast), self.llm_engine)
203
+ avalanche_summary = llm_summarizer(str(avalanche_conditions), self.llm_engine)
204
+
205
+
206
+ return {"forecast": forecast_summary, "avalanche_conditions": avalanche_summary}
src/utils.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import re
3
+ import json
4
+ import googlemaps
5
+ import os
6
+ from math import radians, sin, cos, sqrt, atan2
7
+ import numpy as np
8
+ from typing import Tuple, Dict, List
9
+ from openai import OpenAI
10
+
11
+ def geocode_location(query: str) -> Tuple[float, float]:
12
+ """
13
+ Geocode a location query into latitude and longitude.
14
+
15
+ Args:
16
+ query (str): Location query string.
17
+
18
+ Returns:
19
+ Tuple[float, float]: Latitude and longitude of the location.
20
+ """
21
+ gmaps = googlemaps.Client(key=os.getenv('GOOGLE_MAPS_API_KEY'))
22
+ geocode_result = gmaps.places(query)
23
+ try:
24
+ location = geocode_result['results'][0]['geometry']['location']
25
+ return location['lat'], location['lng']
26
+ except:
27
+ return None
28
+
29
+ def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
30
+ """
31
+ Calculate the great circle distance in kilometers between two points on Earth.
32
+
33
+ Args:
34
+ lat1 (float): Latitude of the first point.
35
+ lon1 (float): Longitude of the first point.
36
+ lat2 (float): Latitude of the second point.
37
+ lon2 (float): Longitude of the second point.
38
+
39
+ Returns:
40
+ float: Distance in kilometers.
41
+ """
42
+ R = 6371
43
+ lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
44
+ dlat = lat2 - lat1
45
+ dlon = lon2 - lon1
46
+ a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
47
+ c = 2 * atan2(sqrt(a), sqrt(1 - a))
48
+ return R * c
49
+
50
+ def assign_location_to_clusters(location: Tuple[float, float], clusters: Dict[str, List[Tuple[float, float]]], k: int = 3) -> List[Tuple[str, float]]:
51
+ """
52
+ Assign a location to the closest clusters of mountain ranges based on proximity.
53
+
54
+ Args:
55
+ location (Tuple[float, float]): Latitude and longitude of the location.
56
+ clusters (Dict[str, List[Tuple[float, float]]]): Clusters of mountain ranges.
57
+ k (int): Number of closest clusters to return.
58
+
59
+ Returns:
60
+ List[Tuple[str, float]]: Closest clusters and their distances.
61
+ """
62
+ closest_summits = []
63
+ for range_label, points in clusters.items():
64
+ min_distance = float('inf')
65
+ for lat, lon in points:
66
+ dist = haversine(location[0], location[1], lat, lon)
67
+ if dist < min_distance:
68
+ min_distance = dist
69
+ closest_summits.append((range_label, min_distance))
70
+ closest_summits.sort(key=lambda x: x[1])
71
+ return closest_summits[:k]
72
+
73
+ def build_clustered_mountain_ranges(peaks):
74
+ """
75
+ Group peaks into clusters based on their mountain range labels.
76
+ :param peaks: list of dicts [{'name': str, 'lat': float, 'lon': float, 'range': str}]
77
+ :return: dict of clusters {range_label: [(lat, lon)]}
78
+ """
79
+ clusters = {}
80
+ for peak in peaks:
81
+ range_label = peak['range']
82
+ if range_label not in clusters:
83
+ clusters[range_label] = []
84
+ clusters[range_label].append((peak['lat'], peak['lon']))
85
+ return clusters
86
+
87
+
88
+
89
+ def parse_topo_blob(output: str):
90
+ """
91
+ Checks if there is any dictionary-like structure in the output string.
92
+
93
+ Args:
94
+ output (str): The output string potentially containing a dictionary.
95
+
96
+ Returns:
97
+ dict or None: Parsed dictionary if found, otherwise None.
98
+ """
99
+ # Define a regex pattern to match any dictionary-like structure
100
+ pattern = r"\{\s*(?:\"[^\"]+\":\s*(?:\".*?\"|\d+|true|false|null),?\s*)+\}" # Matches any JSON-like dict
101
+
102
+ # Search for the pattern in the output
103
+ match = re.search(pattern, output, re.DOTALL)
104
+ if match:
105
+ blob = match.group()
106
+ try:
107
+ # Parse the JSON-like dict
108
+ return json.loads(blob)
109
+ except json.JSONDecodeError as e:
110
+ print(f"Error decoding JSON: {e}")
111
+ return None
112
+
113
+ return None
114
+
115
+ def llm_summarizer(text, llm_engine):
116
+
117
+
118
+ messages=[
119
+ {
120
+ "role": "system",
121
+ "content": "You're an expert at summarizing data on weather forecast and avalanche conditions. Summarize the data that's been provided to you below"
122
+ },
123
+ {
124
+ "role": "user",
125
+ "content": text,
126
+ }
127
+ ]
128
+
129
+ summary = llm_engine(messages)
130
+ return summary["content"]
131
+
132
+
133
+
134
+
135
+