Spaces:
Running
Running
Commit
·
7f4e4c3
1
Parent(s):
536d08f
Added source modules of the app
Browse files- src/gradio_utils.py +149 -0
- src/meteo_france_api.py +72 -0
- src/prompts.py +283 -0
- src/skitour_api.py +140 -0
- src/tools.py +206 -0
- 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 |
+
|