VincentGOURBIN's picture
Upload Application Gradio principale
76e177e verified
import gradio as gr
import requests
import json
from typing import Dict, List, Tuple, Optional
from datetime import datetime, timedelta
import os
from dotenv import load_dotenv
import logging
import time
import locale
# Configuration locale pour les dates en français
try:
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8')
except:
try:
locale.setlocale(locale.LC_TIME, 'French_France.1252')
except:
pass # Garde la locale par défaut si français non disponible
# Configuration du logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
load_dotenv()
def format_timestamp(timestamp, format_type="datetime"):
"""
Convertit un timestamp Unix en format français lisible.
Args:
timestamp (int|float): Timestamp Unix à convertir
format_type (str): Type de format souhaité
- "datetime": "01/07/2025 à 14:30" (par défaut)
- "date": "Mar 01/07"
- "time": "14:30"
- autre: format strftime personnalisé
Returns:
str: Date formatée en français ou "--" si erreur
"""
if not timestamp or timestamp == 0:
return "--"
try:
dt = datetime.fromtimestamp(timestamp)
if format_type == "date":
return dt.strftime("%a %d/%m")
elif format_type == "time":
return dt.strftime("%H:%M")
elif format_type == "datetime":
return dt.strftime("%d/%m/%Y à %H:%M")
else:
return dt.strftime(format_type)
except (ValueError, OSError):
return str(timestamp)
class MeteoFranceAPI:
"""
Client pour l'API privée Météo-France.
Utilise l'API interne de Météo-France (même que l'app mobile officielle)
pour récupérer prévisions, observations, alertes et données de pluie.
Attributes:
base_url (str): URL de base de l'API Météo-France
token (str): Token d'authentification depuis variable d'environnement
headers (dict): Headers HTTP pour les requêtes
"""
def __init__(self):
# API privée Météo-France utilisée par les apps mobiles officielles
self.base_url = "https://webservice.meteofrance.com"
# Token depuis variable d'environnement obligatoire
self.token = os.getenv('METEOFRANCE_TOKEN')
if not self.token:
raise ValueError("Token Météo-France manquant. Définissez METEOFRANCE_TOKEN dans votre fichier .env")
self.headers = {
"User-Agent": "MeteoApp/1.0"
}
def get_location_forecast(self, lat: float, lon: float) -> dict:
"""
Récupère les prévisions météo complètes pour une position géographique.
Args:
lat (float): Latitude en degrés décimaux
lon (float): Longitude en degrés décimaux
Returns:
dict: Données de prévision avec clés principales:
- position: Infos localisation (nom, département, altitude...)
- forecast: Prévisions horaires sur 7 jours
- daily_forecast: Prévisions quotidiennes sur 10 jours
- updated_on: Timestamp de mise à jour
En cas d'erreur: {"error": "message d'erreur"}
"""
logger.info(f"🌤️ Récupération prévisions météo pour: lat={lat}, lon={lon}")
try:
# API privée Météo-France - Prévisions complètes
url = f"{self.base_url}/forecast"
params = {
"lat": lat,
"lon": lon,
"lang": "fr",
"token": self.token # Token passé en paramètre de requête
}
# Masquer le token dans les logs
safe_params = {k: "***" if k == "token" else v for k, v in params.items()}
logger.info(f"📡 Appel API Météo-France: {url}")
logger.info(f"📋 Paramètres: {safe_params}")
response = requests.get(url, headers=self.headers, params=params)
if response.status_code == 200:
data = response.json()
logger.info(f"✅ Données météo reçues")
return data
else:
logger.error(f"❌ Erreur API Météo-France {response.status_code}: {response.text}")
return {"error": f"Erreur API: {response.status_code}"}
except Exception as e:
logger.error(f"❌ Erreur connexion Météo-France: {e}")
return {"error": f"Erreur de connexion: {str(e)}"}
def get_wind_alerts_by_department(self, department: str) -> dict:
"""
Récupère les alertes météo pour un département français spécifique.
Args:
department (str): Code département français (ex: "29", "75", "2A")
Returns:
dict: Données d'alertes avec clés principales:
- domain_id: Code du département
- phenomenons_max_colors: Liste des phénomènes avec niveaux d'alerte
- update_time: Timestamp de mise à jour
- end_validity_time: Fin de validité des alertes
En cas d'erreur: {"error": "message d'erreur"}
"""
logger.info(f"⚠️ Récupération alertes pour département: {department}")
try:
# API privée Météo-France - Alertes météo par département
url = f"{self.base_url}/v3/warning/currentphenomenons"
params = {
"domain": department,
"depth": 1,
"with_coastal_bulletin": "true",
"token": self.token
}
# Masquer le token dans les logs
safe_params = {k: "***" if k == "token" else v for k, v in params.items()}
logger.info(f"📡 Appel API Alertes pour département {department}")
response = requests.get(url, headers=self.headers, params=params)
if response.status_code == 200:
data = response.json()
logger.info(f"✅ Alertes reçues")
return data
else:
logger.error(f"❌ Erreur API Alertes {response.status_code}: {response.text}")
return {"error": f"Erreur API alertes: {response.status_code}"}
except Exception as e:
logger.error(f"❌ Erreur alertes: {e}")
return {"error": f"Erreur alertes: {str(e)}"}
def get_wind_alerts(self, lat: float, lon: float) -> dict:
"""Récupère les alertes vent pour une position (fallback)"""
logger.info(f"⚠️ Récupération alertes vent pour: lat={lat}, lon={lon}")
# Déterminer le département à partir des coordonnées GPS
department = self._get_department_from_coords(lat, lon)
return self.get_wind_alerts_by_department(department)
def _get_department_from_coords(self, lat: float, lon: float) -> str:
"""Approximation simple du département depuis les coordonnées GPS"""
logger.info(f"🗺️ Détermination département pour: lat={lat}, lon={lon}")
# Logique simplifiée pour les principales régions
# Paris et Ile-de-France
if 48.8 <= lat <= 49.0 and 2.2 <= lon <= 2.5:
dept = "75" # Paris
elif 48.1 <= lat <= 49.2 and 1.4 <= lon <= 3.6:
dept = "77" # Seine-et-Marne (approximation IDF)
# Marseille
elif 43.2 <= lat <= 43.4 and 5.3 <= lon <= 5.5:
dept = "13" # Bouches-du-Rhône
# Lyon
elif 45.7 <= lat <= 45.8 and 4.8 <= lon <= 4.9:
dept = "69" # Rhône
# Toulouse
elif 43.5 <= lat <= 43.7 and 1.3 <= lon <= 1.5:
dept = "31" # Haute-Garonne
# Par défaut, utiliser "france" pour l'ensemble du territoire
else:
dept = "france"
logger.info(f"🏷️ Département déterminé: {dept}")
return dept
def get_current_observation(self, lat: float, lon: float) -> dict:
"""Récupère les observations météo actuelles"""
logger.info(f"🌡️ Récupération observations actuelles pour: lat={lat}, lon={lon}")
try:
url = f"{self.base_url}/v2/observation"
params = {
"lat": lat,
"lon": lon,
"lang": "fr",
"token": self.token # Token passé en paramètre de requête
}
response = requests.get(url, headers=self.headers, params=params)
if response.status_code == 200:
data = response.json()
logger.info(f"✅ Observations reçues")
return data
else:
logger.error(f"❌ Erreur API Observations {response.status_code}: {response.text}")
return {"error": f"Erreur API observations: {response.status_code}"}
except Exception as e:
logger.error(f"❌ Erreur observations: {e}")
return {"error": f"Erreur observations: {str(e)}"}
def get_rain_forecast(self, lat: float, lon: float) -> dict:
"""Récupère les prévisions de pluie dans l'heure"""
logger.info(f"🌧️ Récupération prévisions pluie pour: lat={lat}, lon={lon}")
try:
url = f"{self.base_url}/rain"
params = {
"lat": round(lat, 3), # Précision minimum requise
"lon": round(lon, 3),
"lang": "fr",
"token": self.token # Token passé en paramètre de requête
}
response = requests.get(url, headers=self.headers, params=params)
if response.status_code == 200:
data = response.json()
logger.info(f"✅ Prévisions pluie reçues")
return data
else:
logger.error(f"❌ Erreur API Pluie {response.status_code}: {response.text}")
return {"error": f"Erreur API pluie: {response.status_code}"}
except Exception as e:
logger.error(f"❌ Erreur pluie: {e}")
return {"error": f"Erreur pluie: {str(e)}"}
class GeocodingAPI:
"""
Client pour l'API de géocodage IGN Géoplateforme.
Utilise l'API officielle française pour convertir des adresses
en coordonnées GPS avec métadonnées françaises précises.
Attributes:
base_url (str): URL de l'API de géocodage IGN
"""
def __init__(self):
# L'API IGN ne nécessite pas de clé d'accès
self.base_url = "https://data.geopf.fr/geocodage/search"
def geocode_address(self, address: str) -> dict:
"""
Convertit une adresse française en coordonnées GPS via l'API IGN.
Args:
address (str): Adresse à géolocaliser (ex: "Brest, France")
Returns:
dict: Résultat avec clés:
- lat (float): Latitude en degrés décimaux
- lon (float): Longitude en degrés décimaux
- full_data (dict): Données complètes IGN (propriétés, géométrie...)
None en cas d'erreur
"""
logger.info(f"🔍 Géocodage IGN de l'adresse: {address}")
try:
params = {
"q": address,
"index": "address", # Recherche par adresse
"limit": 1,
"returntruegeometry": "true"
}
response = requests.get(self.base_url, params=params)
if response.status_code == 200:
data = response.json()
logger.info(f"✅ Géolocalisation réussie")
if data.get("features") and len(data["features"]) > 0:
feature = data["features"][0]
coords = feature["geometry"]["coordinates"]
logger.info(f"📍 Coordonnées trouvées: lat={coords[1]}, lon={coords[0]}")
return {
"lat": coords[1],
"lon": coords[0],
"full_data": feature # Conserver toutes les données pour la carte
}
else:
logger.warning("⚠️ Aucune coordonnée trouvée dans la réponse IGN")
else:
logger.error(f"❌ Erreur HTTP IGN {response.status_code}: {response.text}")
return None
except Exception as e:
logger.error(f"❌ Erreur géocodage IGN: {e}")
return None
def format_weather_data(data: dict) -> str:
"""
Formate les données météo brutes en texte lisible markdown.
Args:
data (dict): Données météo brutes de l'API Météo-France
avec clés position, forecast, daily_forecast, updated_on
Returns:
str: Données météo formatées en markdown avec prévisions
quotidiennes et horaires, ou message d'erreur si échec
"""
if "error" in data:
return f"❌ {data['error']}"
formatted = "🌤️ **Prévisions Météo**\n\n"
try:
if "position" in data:
pos = data["position"]
formatted += f"📍 **Localisation:** {pos.get('name', 'Inconnu')}\n"
formatted += f"Coordonnées: {pos.get('lat', 0):.3f}, {pos.get('lon', 0):.3f}\n\n"
if "updated_on" in data:
update_time = format_timestamp(data['updated_on'])
formatted += f"⏰ **Mise à jour:** {update_time}\n\n"
# Prévisions quotidiennes
if "daily_forecast" in data and data["daily_forecast"]:
formatted += "📅 **Prévisions à 10 jours:**\n"
for day in data["daily_forecast"][:10]:
timestamp = day.get("dt", 0)
date_str = format_timestamp(timestamp, "date")
temp_min = day.get("T", {}).get("min", "--")
temp_max = day.get("T", {}).get("max", "--")
weather = day.get("weather12H", {}).get("desc", "--")
formatted += f" • {date_str}: {temp_min}°/{temp_max}°C - {weather}\n"
formatted += "\n"
# Prévisions horaires (aujourd'hui)
if "forecast" in data and data["forecast"]:
formatted += "🕰️ **Aujourd'hui (par heure):**\n"
for hour in data["forecast"][:12]: # 12 premières heures
timestamp = hour.get("dt", 0)
time_str = format_timestamp(timestamp, "time")
temp = hour.get("T", {}).get("value", "--")
weather = hour.get("weather", {}).get("desc", "--")
formatted += f" • {time_str}: {temp}°C - {weather}\n"
except Exception as e:
logger.error(f"Erreur formatage météo: {e}")
formatted += f"Données brutes: {json.dumps(data, indent=2, ensure_ascii=False)[:500]}..."
return formatted
def format_current_conditions(current_data: dict, rain_data: dict = None, forecast_data: dict = None) -> str:
"""
Formate les conditions météorologiques actuelles avec détails complets.
Combine observations actuelles, prévisions de pluie et données de localisation
pour créer un rapport météo actuel détaillé.
Args:
current_data (dict): Observations météo actuelles de l'API
rain_data (dict, optional): Prévisions pluie dans l'heure. Defaults to None.
forecast_data (dict, optional): Données de prévision pour la localisation. Defaults to None.
Returns:
str: Conditions actuelles formatées en markdown avec température,
vent, précipitations, humidité, pression et visibilité
"""
if "error" in current_data:
return f"❌ {current_data['error']}"
formatted = "🌡️ **Conditions Actuelles**\n\n"
try:
# Localisation
if forecast_data and "position" in forecast_data:
pos = forecast_data["position"]
formatted += f"📍 **Lieu**: {pos.get('name', 'Localisation inconnue')}\n"
formatted += f"Coordonnées: {pos.get('lat', 0):.3f}, {pos.get('lon', 0):.3f}\n\n"
# Heure d'observation depuis properties.gridded.time
gridded = current_data.get("properties", {}).get("gridded", {})
if "time" in gridded:
obs_time = gridded["time"]
# Convertir format ISO vers format lisible
if obs_time:
try:
from datetime import datetime
dt = datetime.fromisoformat(obs_time.replace('Z', '+00:00'))
formatted += f"⏰ **Observation**: {dt.strftime('%d/%m/%Y à %H:%M')}\n\n"
except:
formatted += f"⏰ **Observation**: {obs_time}\n\n"
# Température depuis properties.gridded.T
temp = gridded.get("T", "--")
formatted += f"🌡️ **Température**: {temp}°C\n"
# Vent depuis properties.gridded
wind_speed = gridded.get("wind_speed", "--")
wind_dir = gridded.get("wind_direction", "--")
wind_icon = gridded.get("wind_icon", "")
formatted += f"🌬️ **Vent**: {wind_speed} km/h - {wind_dir}° {wind_icon}\n"
# Conditions météo depuis properties.gridded
weather_desc = gridded.get("weather_description", "--")
weather_icon = gridded.get("weather_icon", "")
formatted += f"🌤️ **Temps**: {weather_desc} {weather_icon}\n"
# Affichage des précipitations (selon meteofrance-api officiel)
if rain_data and "error" not in rain_data:
# Vérifier la disponibilité du service pluie
rain_available = rain_data.get("position", {}).get("rain_product_available", 0)
if rain_available == 0:
formatted += f"💧 **Précipitations**: Service radar indisponible pour cette zone\n"
elif "forecast" in rain_data and rain_data["forecast"]:
# Prendre la première prévision (maintenant)
current_rain = rain_data["forecast"][0] if rain_data["forecast"] else None
if current_rain:
rain_intensity = current_rain.get("rain", 0)
rain_desc = current_rain.get("desc", "Pas de données")
# Interprétation selon meteofrance-api : > 1 = pluie
if rain_intensity <= 1:
formatted += f"💧 **Précipitations**: Aucune - {rain_desc}\n"
elif rain_intensity == 2:
formatted += f"💧 **Précipitations**: 🌦️ Pluie faible - {rain_desc}\n"
elif rain_intensity == 3:
formatted += f"💧 **Précipitations**: 🌧️ Pluie modérée - {rain_desc}\n"
elif rain_intensity >= 4:
formatted += f"💧 **Précipitations**: ⛈️ Pluie forte - {rain_desc}\n"
else:
formatted += f"💧 **Précipitations**: Intensité {rain_intensity} - {rain_desc}\n"
else:
formatted += f"💧 **Précipitations**: Données non disponibles\n"
else:
formatted += f"💧 **Précipitations**: Pas de prévisions disponibles\n"
else:
formatted += f"💧 **Précipitations**: Service indisponible\n"
# Humidité
humidity = current_data.get("humidity", "--")
if humidity != "--":
formatted += f"💧 **Humidité**: {humidity}%\n"
# Pression
pressure = current_data.get("pressure", {}).get("value", "--")
if pressure != "--":
formatted += f"📊 **Pression**: {pressure} hPa\n"
# Visibilité
visibility = current_data.get("visibility", "--")
if visibility != "--":
formatted += f"👁️ **Visibilité**: {visibility} km\n"
# Prévisions de pluie dans l'heure (améliorées)
if rain_data and "error" not in rain_data:
formatted += "\n🌧️ **Pluie dans l'heure:**\n"
# Informations sur la localisation pluie
if "position" in rain_data:
rain_pos = rain_data["position"]
quality = rain_data.get("quality", 0)
updated = rain_data.get("updated_on", "")
if isinstance(updated, (int, float)):
update_str = format_timestamp(updated)
else:
update_str = updated
formatted += f"📍 Lieu: {rain_pos.get('name', 'Inconnu')} (Dept. {rain_pos.get('dept', '??')})\n"
formatted += f"⏰ Mis à jour: {update_str}\n"
formatted += f"📊 Qualité: {quality}/10\n\n"
if "forecast" in rain_data and rain_data["forecast"]:
next_rain = rain_data["forecast"][:6] # 6 prochains points (1h30)
for point in next_rain:
rain_intensity = point.get("rain", 0)
rain_desc_api = point.get("desc", "")
time_point = point.get("dt", "")
if isinstance(time_point, (int, float)):
time_str = format_timestamp(time_point, "time")
else:
time_str = time_point
# Utiliser la description de l'API si disponible, sinon notre mapping
if rain_desc_api and rain_desc_api != "Pas de valeur":
rain_desc = rain_desc_api
else:
if rain_intensity == 0:
rain_desc = "Pas de pluie"
elif rain_intensity == 1:
rain_desc = "Pluie faible"
elif rain_intensity == 2:
rain_desc = "Pluie modérée"
elif rain_intensity == 3:
rain_desc = "Pluie forte"
else:
rain_desc = f"Intensité {rain_intensity}"
# Émoji selon l'intensité
if rain_intensity == 0:
emoji = "☀️"
elif rain_intensity == 1:
emoji = "🌦️"
elif rain_intensity == 2:
emoji = "🌧️"
elif rain_intensity >= 3:
emoji = "⛈️"
else:
emoji = "❓"
formatted += f" • {time_str}: {emoji} {rain_desc}\n"
else:
formatted += " • Données non disponibles\n"
except Exception as e:
logger.error(f"Erreur formatage conditions actuelles: {e}")
formatted += f"\nErreur formatage: {e}\n"
return formatted
def format_hourly_forecast(forecast_data: dict, rain_data: dict = None) -> str:
"""
Formate les prévisions météo horaires sur 24h avec intégration des précipitations.
Args:
forecast_data (dict): Données de prévision horaire de l'API Météo-France
rain_data (dict, optional): Données de précipitations à intégrer. Defaults to None.
Returns:
str: Prévisions horaires formatées en markdown avec température,
météo, vent et informations de pluie pour chaque heure
"""
if "error" in forecast_data:
return f"❌ {forecast_data['error']}"
formatted = "🕰️ **Prévisions Heure par Heure (24h)**\n\n"
try:
# Créer un dictionnaire des prévisions pluie par timestamp pour lookup rapide
rain_by_time = {}
if rain_data and "forecast" in rain_data and rain_data["forecast"]:
for rain_point in rain_data["forecast"]:
rain_timestamp = rain_point.get("dt", 0)
rain_by_time[rain_timestamp] = rain_point
if "forecast" in forecast_data and forecast_data["forecast"]:
for hour in forecast_data["forecast"][:24]:
timestamp = hour.get("dt", 0)
time_str = format_timestamp(timestamp, "time")
temp = hour.get("T", {}).get("value", "--")
weather = hour.get("weather", {}).get("desc", "--")
wind_speed = hour.get("wind", {}).get("speed", "--")
wind_dir = hour.get("wind", {}).get("direction", "--")
wind_gust = hour.get("wind", {}).get("gust", "--")
# Récupérer les données de pluie pour cette heure
rain_info = ""
if timestamp in rain_by_time:
rain_point = rain_by_time[timestamp]
rain_intensity = rain_point.get("rain", 0)
rain_desc = rain_point.get("desc", "")
if rain_intensity <= 1:
rain_info = " - ☀️ Sec"
elif rain_intensity == 2:
rain_info = " - 🌦️ Pluie faible"
elif rain_intensity == 3:
rain_info = " - 🌧️ Pluie modérée"
elif rain_intensity >= 4:
rain_info = " - ⛈️ Pluie forte"
else:
rain_info = f" - 💧 Intensité {rain_intensity}"
formatted += f"**{time_str}**: {temp}°C - {weather}{rain_info}\n"
formatted += f" Vent: {wind_speed} km/h ({wind_dir}°)"
if wind_gust != "--" and wind_gust != 0:
formatted += f" - Rafales: {wind_gust} km/h"
formatted += "\n\n"
except Exception as e:
logger.error(f"Erreur formatage prévisions horaires: {e}")
formatted += f"Erreur: {e}\n"
return formatted
def format_daily_forecast(forecast_data: dict) -> str:
"""
Formate les prévisions météorologiques quotidiennes sur 10 jours.
Args:
forecast_data (dict): Données de prévision quotidienne de l'API Météo-France
avec clé daily_forecast contenant la liste des jours
Returns:
str: Prévisions sur 10 jours formatées en markdown avec températures
min/max, description météo et données de vent pour chaque jour
"""
if "error" in forecast_data:
return f"❌ {forecast_data['error']}"
formatted = "📅 **Prévisions à 10 Jours**\n\n"
try:
if "daily_forecast" in forecast_data and forecast_data["daily_forecast"]:
for day in forecast_data["daily_forecast"][:10]:
timestamp = day.get("dt", 0)
date_str = format_timestamp(timestamp, "date")
temp_min = day.get("T", {}).get("min", "--")
temp_max = day.get("T", {}).get("max", "--")
weather = day.get("weather12H", {}).get("desc", "--")
wind_speed = day.get("wind", {}).get("speed", "--")
wind_dir = day.get("wind", {}).get("direction", "--")
formatted += f"**{date_str}**: {temp_min}°/{temp_max}°C\n"
formatted += f" {weather}\n"
if wind_speed != "--":
formatted += f" Vent: {wind_speed} km/h ({wind_dir}°)\n"
formatted += "\n"
except Exception as e:
logger.error(f"Erreur formatage prévisions 10 jours: {e}")
formatted += f"Erreur: {e}\n"
return formatted
def format_wind_data(forecast_data: dict, current_data: dict = None) -> str:
"""
Formate les données de vent actuelles et prévisions pour l'affichage.
Args:
forecast_data (dict): Prévisions météo avec données de vent horaires et quotidiennes
current_data (dict, optional): Observations actuelles de vent. Defaults to None.
Returns:
str: Données de vent formatées en markdown avec conditions actuelles,
prévisions horaires sur 24h et prévisions quotidiennes sur 10 jours
"""
if "error" in forecast_data:
return f"❌ {forecast_data['error']}"
formatted = "💨 **Informations Vent**\n\n"
try:
# Conditions actuelles de vent
if current_data and "error" not in current_data:
formatted += "🌬️ **Conditions actuelles:**\n"
wind_speed = current_data.get("wind_speed", "--")
wind_dir = current_data.get("wind_direction", "--")
wind_icon = current_data.get("wind_icon", "")
formatted += f" • Vitesse: {wind_speed} km/h\n"
formatted += f" • Direction: {wind_dir}° {wind_icon}\n\n"
# Prévisions de vent par heure
if "forecast" in forecast_data and forecast_data["forecast"]:
formatted += "🕰️ **Prévisions vent (24h):**\n"
for hour in forecast_data["forecast"][:24]:
timestamp = hour.get("dt", 0)
time_str = format_timestamp(timestamp, "time")
wind_speed = hour.get("wind", {}).get("speed", "--")
wind_dir = hour.get("wind", {}).get("direction", "--")
wind_gust = hour.get("wind", {}).get("gust", "--")
formatted += f" • {time_str}: {wind_speed} km/h"
if wind_gust != "--" and wind_gust != 0:
formatted += f" (rafales: {wind_gust} km/h)"
formatted += f" - {wind_dir}°\n"
# Prévisions quotidiennes de vent
if "daily_forecast" in forecast_data and forecast_data["daily_forecast"]:
formatted += "\n📅 **Vent à 10 jours:**\n"
for day in forecast_data["daily_forecast"][:10]:
timestamp = day.get("dt", 0)
date_str = format_timestamp(timestamp, "date")
wind_speed = day.get("wind", {}).get("speed", "--")
wind_dir = day.get("wind", {}).get("direction", "--")
formatted += f" • {date_str}: {wind_speed} km/h - {wind_dir}°\n"
except Exception as e:
logger.error(f"Erreur formatage vent: {e}")
formatted += f"Données brutes: {json.dumps(forecast_data, indent=2, ensure_ascii=False)[:500]}..."
return formatted
def format_alerts_data(data: dict) -> str:
"""
Formate les alertes météorologiques Météo-France selon leur niveau de danger.
Analyse les données d'alertes brutes, détermine le niveau maximum d'alerte
et formate l'affichage avec codes couleur appropriés (vert/jaune/orange/rouge).
Args:
data (dict): Données d'alertes de l'API Météo-France avec clés:
- phenomenons_max_colors: Liste des phénomènes avec niveaux
- domain_id: Code département
- update_time: Timestamp de mise à jour
Returns:
str: Alertes formatées en markdown avec niveau global, détail des
phénomènes actifs et descriptions selon référentiel officiel
"""
if "error" in data:
return f"❌ {data['error']}"
try:
# Calculer le niveau d'alerte maximum selon la logique meteofrance-api
max_color_id = 1 # Par défaut vert
active_alerts = []
# Traitement pour les données directes de l'API
if "phenomenons_max_colors" in data and data["phenomenons_max_colors"] is not None:
for item in data["phenomenons_max_colors"]:
if item is not None:
color_id = item.get("phenomenon_max_color_id", 1)
if color_id > max_color_id:
max_color_id = color_id
if color_id > 1:
active_alerts.append(item)
# Traitement pour le format avec timelaps (cas multi-départements)
elif "timelaps" in data and isinstance(data["timelaps"], list):
# Trouver le département avec le niveau d'alerte le plus élevé
# ou chercher un département spécifique si on peut l'identifier
target_dept = None
# data["timelaps"] est une liste de départements
for dept_data in data["timelaps"]:
if dept_data and "phenomenons_max_color" in dept_data:
dept_max = 1
dept_alerts = []
for item in dept_data["phenomenons_max_color"]:
if item is not None:
color_id = item.get("phenomenon_max_color_id", 1)
dept_max = max(dept_max, color_id)
if color_id > 1:
dept_alerts.append(item)
# Si ce département a un niveau plus élevé que l'actuel
if dept_max > max_color_id:
max_color_id = dept_max
active_alerts = dept_alerts
target_dept = dept_data.get("domain_id", "Inconnu")
# Traitement pour les sous-domaines si pas d'alertes directes
elif "subdomains_phenomenons_max_color" in data and data["subdomains_phenomenons_max_color"] is not None:
for subdomain in data["subdomains_phenomenons_max_color"]:
if subdomain and "phenomenons_max_color" in subdomain and subdomain["phenomenons_max_color"] is not None:
for item in subdomain["phenomenons_max_color"]:
if item is not None:
color_id = item.get("phenomenon_max_color_id", 1)
if color_id > max_color_id:
max_color_id = color_id
if color_id > 1:
active_alerts.append(item)
# Traitement pour le format alternatif
elif "phenomenon_items" in data:
for phenomenon_id, phenomenon_data in data["phenomenon_items"].items():
color_id = phenomenon_data.get("phenomenon_max_color_id", 1)
if color_id > max_color_id:
max_color_id = color_id
if color_id > 1:
active_alerts.append({
"phenomenon_id": phenomenon_id,
"phenomenon_max_color_id": color_id
})
# Noms des phénomènes (référence officielle meteofrance-api)
phenomena_names = {
"0": None,
"1": "Vent violent",
"2": "Pluie-inondation",
"3": "Orages",
"4": "Inondation",
"5": "Neige-verglas",
"6": "Canicule",
"7": "Grand-froid",
"8": "Avalanches",
"9": "Vagues-submersion"
}
# Correspondance couleurs (CORRIGÉE)
colors = {
1: "🟢 Vert",
2: "🟡 Jaune",
3: "🟠 Orange",
4: "🔴 Rouge"
}
color_descriptions = {
1: "Pas de vigilance particulière",
2: "Soyez attentifs",
3: "Soyez très vigilants",
4: "Vigilance absolue"
}
# Formatage selon le niveau maximum
if max_color_id >= 4:
formatted = "🔴 **ALERTE ROUGE - VIGILANCE ABSOLUE**\n\n"
elif max_color_id >= 3:
formatted = "🟠 **ALERTE ORANGE - SOYEZ TRÈS VIGILANTS**\n\n"
elif max_color_id >= 2:
formatted = "🟡 **VIGILANCE JAUNE - SOYEZ ATTENTIFS**\n\n"
else:
formatted = "🟢 **Pas d'alerte en cours**\n\n"
# Affichage du département si disponible
if 'target_dept' in locals() and target_dept:
formatted += f"📍 **Département**: {target_dept}\n"
elif "domain_id" in data:
formatted += f"📍 **Département**: {data['domain_id']}\n"
# Niveau global
formatted += f"🏷️ **Niveau global**: {colors.get(max_color_id, 'Inconnu')} - {color_descriptions.get(max_color_id, '')}\n\n"
# Détail des alertes actives
if active_alerts:
formatted += "🚨 **Détail des alertes:**\n"
for alert in active_alerts:
phenomenon_id = str(alert.get("phenomenon_id", ""))
color_id = alert.get("phenomenon_max_color_id", 1)
phenomenon_name = phenomena_names.get(phenomenon_id, f"Phénomène {phenomenon_id}")
color_desc = colors.get(color_id, f"Niveau {color_id}")
formatted += f" • **{phenomenon_name}**: {color_desc}\n"
# Timestamp de mise à jour
if "update_time" in data:
update_time = data['update_time']
if isinstance(update_time, (int, float)):
time_str = format_timestamp(update_time)
else:
time_str = update_time
formatted += f"\n⏰ **Mise à jour**: {time_str}\n"
except Exception as e:
logger.error(f"Erreur formatage alertes: {e}")
formatted = f"❌ Erreur formatage alertes: {e}\n\n"
formatted += f"Données brutes: {json.dumps(data, indent=2, ensure_ascii=False)[:500]}..."
return formatted
def get_weather_forecast(address: str):
"""
Récupère les prévisions météo complètes pour une adresse française.
Effectue la géolocalisation via IGN, récupère les données météo via l'API officielle
Météo-France, et formate les résultats pour l'affichage avec alertes, conditions
actuelles et prévisions.
Args:
address (str): Adresse française à géolocaliser (ex: "Brest, France", "75001 Paris", "Nice")
Returns:
tuple: Quatre éléments formatés en markdown:
- alerts_info (str): Alertes météo avec niveaux de vigilance (vert/jaune/orange/rouge)
- current_conditions (str): Conditions actuelles (température, vent, précipitations)
- hourly_forecast (str): Prévisions horaires sur 24h avec données de pluie
- daily_forecast (str): Prévisions quotidiennes sur 10 jours
Note:
Utilise les APIs officielles françaises :
- IGN Géoplateforme pour la géolocalisation
- Météo-France pour les données météorologiques
Examples:
>>> alerts, current, hourly, daily = get_weather_forecast("Paris, France")
>>> print(current) # Affiche température, vent, etc.
"""
logger.info(f"🚀 Début de la requête pour: {address}")
if not address.strip():
logger.warning("⚠️ Adresse vide fournie")
error_msg = "❌ Veuillez saisir une adresse"
return error_msg, error_msg, error_msg, error_msg
# Géocodage de l'adresse
geocoder = GeocodingAPI()
geocoding_result = geocoder.geocode_address(address)
if not geocoding_result:
error_msg = "❌ Impossible de localiser cette adresse"
logger.error(f"❌ Géocodage échoué pour: {address}")
return error_msg, error_msg, error_msg, error_msg
lat = geocoding_result["lat"]
lon = geocoding_result["lon"]
logger.info(f"✅ Coordonnées obtenues: lat={lat}, lon={lon}")
# Récupération des données météo
meteo_api = MeteoFranceAPI()
weather_data = meteo_api.get_location_forecast(lat, lon)
current_weather = meteo_api.get_current_observation(lat, lon)
rain_forecast = meteo_api.get_rain_forecast(lat, lon)
# Utiliser le département depuis l'API forecast pour les alertes
department = None
if weather_data and "position" in weather_data:
department = weather_data["position"].get("dept")
if department:
logger.info(f"🏷️ Utilisation département depuis API forecast: {department}")
wind_alerts = meteo_api.get_wind_alerts_by_department(department)
else:
logger.warning("⚠️ Pas de département trouvé, utilisation géolocalisation")
wind_alerts = meteo_api.get_wind_alerts(lat, lon)
# Formatage des résultats
alerts_info = format_alerts_data(wind_alerts)
current_conditions = format_current_conditions(current_weather, rain_forecast, weather_data)
hourly_forecast = format_hourly_forecast(weather_data, rain_forecast)
daily_forecast = format_daily_forecast(weather_data)
logger.info("✅ Requête terminée avec succès")
return alerts_info, current_conditions, hourly_forecast, daily_forecast
# Fonctions individuelles pour MCP
def get_weather_alerts(address: str) -> str:
"""
Récupère les alertes météorologiques pour une adresse française.
Retourne les niveaux de vigilance Météo-France (vert/jaune/orange/rouge)
avec détail des phénomènes dangereux.
Args:
address (str): Adresse française à analyser (ex: "Paris, France", "29200 Brest")
Returns:
str: Alertes météo formatées avec niveaux de vigilance et phénomènes actifs
"""
alerts_info, _, _, _ = get_weather_forecast(address)
return alerts_info
def get_current_weather(address: str) -> str:
"""
Récupère les conditions météorologiques actuelles pour une adresse.
Fournit température, vent, précipitations, humidité, pression et visibilité
en temps réel depuis les stations Météo-France.
Args:
address (str): Adresse française à analyser (ex: "Nice", "75001 Paris")
Returns:
str: Conditions actuelles détaillées avec données de vent et précipitations
"""
_, current_conditions, _, _ = get_weather_forecast(address)
return current_conditions
def get_hourly_forecast(address: str) -> str:
"""
Récupère les prévisions météorologiques horaires sur 24h.
Prévisions détaillées heure par heure avec température, météo,
vent, rafales et précipitations intégrées.
Args:
address (str): Adresse française à analyser (ex: "Toulouse", "13001 Marseille")
Returns:
str: Prévisions horaires sur 24h avec toutes les données météo
"""
_, _, hourly_forecast, _ = get_weather_forecast(address)
return hourly_forecast
def get_daily_forecast(address: str) -> str:
"""
Récupère les prévisions météorologiques quotidiennes sur 10 jours.
Prévisions étendues avec températures minimales et maximales,
conditions générales et données de vent quotidiennes.
Args:
address (str): Adresse française à analyser (ex: "Bordeaux", "69001 Lyon")
Returns:
str: Prévisions quotidiennes sur 10 jours avec températures et météo
"""
_, _, _, daily_forecast = get_weather_forecast(address)
return daily_forecast
def get_complete_weather_forecast(address: str) -> str:
"""
Récupère un rapport météorologique complet pour une adresse française.
Combine alertes, conditions actuelles, prévisions horaires et quotidiennes
en un rapport unifié depuis les APIs officielles françaises.
Args:
address (str): Adresse française à analyser (ex: "Brest", "06000 Nice")
Returns:
str: Rapport météo complet avec alertes, conditions actuelles et prévisions
"""
alerts_info, current_conditions, hourly_forecast, daily_forecast = get_weather_forecast(address)
complete_report = f"""
{alerts_info}
{current_conditions}
{hourly_forecast}
{daily_forecast}
"""
return complete_report
def get_weather_emoji(description: str) -> str:
"""
Retourne l'émoji météo approprié selon la description textuelle.
Args:
description (str): Description météo en français (ex: "ensoleillé", "nuageux")
Returns:
str: Émoji Unicode correspondant à la condition météo
(☀️, ☁️, 🌧️, ⛈️, ❄️, 🌫️, ⛅, 🌤️)
"""
description = description.lower()
if "ensoleill" in description or "clair" in description:
return "☀️"
elif "nuage" in description:
return "☁️"
elif "pluie" in description or "averse" in description:
return "🌧️"
elif "orage" in description:
return "⛈️"
elif "neige" in description:
return "❄️"
elif "brouillard" in description:
return "🌫️"
elif "éclaircies" in description:
return "⛅"
else:
return "🌤️"
def create_wind_compass(direction: int, speed: float) -> str:
"""
Génère une boussole HTML interactive pour visualiser la direction du vent.
Crée un élément SVG-like en HTML/CSS avec flèche orientée selon la direction
et couleur selon la vitesse du vent.
Args:
direction (int): Direction du vent en degrés (0-360°, 0° = Nord)
speed (float): Vitesse du vent en km/h
Returns:
str: Code HTML de la boussole avec flèche orientée et colorée:
- Vert: < 10 km/h
- Orange: 10-20 km/h
- Rouge: 20-30 km/h
- Violet: > 30 km/h
"""
if not direction or direction < 0:
direction = 0
# Convertir en radians pour le CSS transform
rotation = direction - 90 # Ajuster pour que 0° = Nord
# Couleur selon la vitesse
if speed < 10:
color = "#4CAF50" # Vert
elif speed < 20:
color = "#FF9800" # Orange
elif speed < 30:
color = "#F44336" # Rouge
else:
color = "#9C27B0" # Violet
compass_html = f"""
<div style="display: inline-block; position: relative; width: 60px; height: 60px;
border: 2px solid {color}; border-radius: 50%; margin: 0 10px;">
<!-- Boussole background -->
<div style="position: absolute; top: 2px; left: 50%; transform: translateX(-50%);
font-size: 8px; color: {color}; font-weight: bold;">N</div>
<div style="position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
font-size: 8px; color: {color}; font-weight: bold;">S</div>
<div style="position: absolute; left: 2px; top: 50%; transform: translateY(-50%);
font-size: 8px; color: {color}; font-weight: bold;">O</div>
<div style="position: absolute; right: 2px; top: 50%; transform: translateY(-50%);
font-size: 8px; color: {color}; font-weight: bold;">E</div>
<!-- Flèche du vent -->
<div style="position: absolute; top: 50%; left: 50%;
width: 3px; height: 20px; background: {color};
transform: translate(-50%, -50%) rotate({rotation}deg);
transform-origin: center; border-radius: 2px;">
<!-- Pointe de la flèche -->
<div style="position: absolute; top: -3px; left: 50%;
transform: translateX(-50%);
width: 0; height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 6px solid {color};"></div>
</div>
<!-- Centre -->
<div style="position: absolute; top: 50%; left: 50%;
width: 4px; height: 4px; background: {color};
border-radius: 50%; transform: translate(-50%, -50%);"></div>
</div>
"""
return compass_html
def create_wind_compass_from_text(wind_text: str) -> str:
"""
Extrait vitesse et direction du vent depuis un texte formaté et génère une boussole.
Parse le texte au format "X.X km/h - Y° [direction]" pour extraire
les valeurs numériques et créer la visualisation boussole.
Args:
wind_text (str): Texte formaté de vent (ex: "12.5 km/h - 270° O")
Returns:
str: Code HTML de boussole générée, ou chaîne vide si parsing échoue
"""
import re
# Regex pour extraire vitesse et direction: "2.8 km/h - 322° NO"
match = re.search(r'(\d+\.?\d*)\s*km/h.*?(\d+)°', wind_text)
if match:
speed = float(match.group(1))
direction = int(match.group(2))
return create_wind_compass(direction, speed)
return ""
def format_alerts_card_html(alerts_info: str) -> str:
"""
Convertit les alertes météo formatées en carte HTML stylisée.
Applique les styles CSS appropriés selon le niveau d'alerte détecté
dans le texte (rouge, orange, jaune, ou standard).
Args:
alerts_info (str): Alertes formatées en markdown depuis format_alerts_data()
Returns:
str: Carte HTML avec classe CSS appropriée au niveau d'alerte:
- alert-card-red: Alerte rouge
- alert-card-orange: Alerte orange
- alert-card-yellow: Alerte jaune
- weather-card: Aucune alerte ou erreur
"""
if "❌" in alerts_info:
return f'<div class="weather-card">{alerts_info}</div>'
# Déterminer la classe CSS selon le niveau d'alerte
if "ROUGE" in alerts_info.upper():
card_class = "alert-card-red"
elif "ORANGE" in alerts_info.upper():
card_class = "alert-card-orange"
elif "JAUNE" in alerts_info.upper():
card_class = "alert-card-yellow"
else:
card_class = "weather-card"
# Nettoyer le texte markdown
clean_text = alerts_info.replace("**", "<strong>").replace("**", "</strong>")
clean_text = clean_text.replace("\n", "<br>")
return f'<div class="{card_class}">{clean_text}</div>'
def format_current_card_html(current_conditions: str) -> str:
"""
Convertit les conditions actuelles en carte HTML avec layout optimisé.
Parse les données markdown pour extraire température, localisation, météo,
vent et précipitations, puis génère une carte avec boussole de vent intégrée.
Args:
current_conditions (str): Conditions actuelles formatées depuis format_current_conditions()
Returns:
str: Carte HTML avec layout responsive affichant:
- Localisation et description météo
- Température avec émoji large
- Boussole de vent interactive
- Informations de précipitations
"""
if "❌" in current_conditions:
return f'<div class="weather-card">{current_conditions}</div>'
# Extraire les informations principales
lines = current_conditions.split("\n")
location = "Localisation inconnue"
temp = "--"
weather_desc = "--"
wind = "--"
observation_time = "--"
precipitation = "Données non disponibles"
for line in lines:
if "Lieu**:" in line:
location = line.split(":", 1)[1].strip()
elif "Température**:" in line:
temp = line.split(":", 1)[1].strip()
elif "Temps**:" in line:
weather_desc = line.split(":", 1)[1].strip().split()[0] # Premier mot
elif "Vent**:" in line:
wind = line.split(":", 1)[1].strip()
elif "Observation**:" in line:
observation_time = line.split(":", 1)[1].strip()
elif "Précipitations**:" in line:
precipitation = line.split(":", 1)[1].strip()
weather_emoji = get_weather_emoji(weather_desc)
temp_num = temp.replace("°C", "")
html = f"""
<div class="current-card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="flex: 1;">
<div style="font-size: 1.1em; opacity: 0.8;">{location}</div>
<div style="font-size: 0.9em; opacity: 0.7;">{weather_desc}</div>
<div style="font-size: 0.8em; opacity: 0.7; margin-top: 2px;">💧 {precipitation}</div>
<div style="font-size: 0.8em; opacity: 0.6; margin-top: 4px;">{observation_time}</div>
</div>
<div style="display: flex; align-items: center; gap: 15px;">
<!-- Vent à droite -->
<div style="text-align: center; font-size: 0.85em;">
<div style="opacity: 0.8; margin-bottom: 5px;">🌬️ Vent</div>
<div style="font-weight: bold;">{wind.split(' - ')[0] if ' - ' in wind else wind}</div>
{create_wind_compass_from_text(wind)}
</div>
<!-- Température -->
<div style="text-align: center;">
<div style="font-size: 2.5em;">{weather_emoji}</div>
<div class="temp-large">{temp_num}°</div>
</div>
</div>
</div>
</div>
"""
return html
def format_hourly_card_html(hourly_forecast: str) -> str:
"""
Convertit les prévisions horaires en carte HTML avec grille horizontale.
Parse les données markdown pour créer une grille de prévisions horaires
avec émojis météo, températures et vitesses de vent.
Args:
hourly_forecast (str): Prévisions horaires formatées depuis format_hourly_forecast()
Returns:
str: Carte HTML avec grille d'éléments horaires (max 12h):
- Heure, émoji météo, température, vitesse vent
- Layout responsive avec items arrondis
"""
if "❌" in hourly_forecast:
return f'<div class="weather-card">{hourly_forecast}</div>'
# Parser les données horaires
lines = hourly_forecast.split("\n")
hourly_items = []
i = 0
while i < len(lines):
line = lines[i].strip()
if "**" in line and ":" in line: # Ligne d'heure
time_part = line.split("**")[1].split(":")[0]
temp_weather = line.split(": ", 1)[1] if ": " in line else "--"
# Récupérer le vent sur la ligne suivante
wind_info = "--"
if i + 1 < len(lines) and "Vent:" in lines[i + 1]:
wind_line = lines[i + 1].strip()
# Extraire seulement la vitesse du vent
if "km/h" in wind_line:
wind_speed = wind_line.split()[1] if len(wind_line.split()) > 1 else "--"
wind_info = f"{wind_speed} km/h"
temp = temp_weather.split(" - ")[0] if " - " in temp_weather else "--"
weather = temp_weather.split(" - ")[1] if " - " in temp_weather else "--"
hourly_items.append({
"time": time_part,
"temp": temp,
"weather": weather,
"wind": wind_info
})
i += 1
# Générer le HTML
items_html = ""
for item in hourly_items[:12]: # Prendre les 12 premières heures
emoji = get_weather_emoji(item["weather"])
items_html += f"""
<div class="hourly-item">
<div style="font-weight: bold; min-width: 50px;">{item["time"]}</div>
<div style="font-size: 1.3em; margin: 0 8px;">{emoji}</div>
<div style="font-weight: bold; min-width: 45px;">{item["temp"]}</div>
<div style="font-size: 0.8em; opacity: 0.8; min-width: 50px; text-align: right;">{item["wind"]}</div>
</div>
"""
html = f"""
<div class="hourly-card">
<div style="font-size: 1.3em; margin-bottom: 12px;"><strong>🕐 Prochaines heures</strong></div>
{items_html}
</div>
"""
return html
def format_daily_card_html(daily_forecast: str) -> str:
"""
Convertit les prévisions quotidiennes en carte HTML compacte.
Parse les prévisions sur 10 jours pour créer une liste verticale
avec émojis météo et plages de températures.
Args:
daily_forecast (str): Prévisions quotidiennes formatées depuis format_daily_forecast()
Returns:
str: Carte HTML avec liste de jours (max 7 affichés):
- Jour, émoji météo, températures min/max
- Items avec fond semi-transparent
"""
if "❌" in daily_forecast:
return f'<div class="weather-card">{daily_forecast}</div>'
# Parser les données quotidiennes
lines = daily_forecast.split("\n")
daily_items = []
i = 0
while i < len(lines):
line = lines[i].strip()
if "**" in line and "°" in line: # Ligne de jour
day_part = line.split("**")[1].split(":")[0]
temps_part = line.split(": ", 1)[1] if ": " in line else "--"
# Récupérer la météo sur la ligne suivante
weather_desc = "--"
if i + 1 < len(lines) and lines[i + 1].strip():
weather_desc = lines[i + 1].strip()
daily_items.append({
"day": day_part,
"temps": temps_part,
"weather": weather_desc
})
i += 1
# Générer le HTML
items_html = ""
for item in daily_items[:7]: # Prendre les 7 premiers jours
emoji = get_weather_emoji(item["weather"])
items_html += f"""
<div class="daily-item">
<div style="font-weight: bold; min-width: 55px; font-size: 0.9em;">{item["day"]}</div>
<div style="font-size: 1.3em; margin: 0 8px;">{emoji}</div>
<div style="font-weight: bold; text-align: right; font-size: 0.9em;">{item["temps"]}</div>
</div>
"""
html = f"""
<div class="daily-card">
<div style="font-size: 1.3em; margin-bottom: 12px;"><strong>📅 Prochains jours</strong></div>
{items_html}
</div>
"""
return html
# Fonctions HTML pour les cartes visuelles (conservées)
def get_weather_forecast_html(address: str):
"""
Version HTML des prévisions météo avec cartes visuelles.
Args:
address (str): Adresse à géolocaliser (ex: "Paris, France")
Returns:
tuple: (alerts_html, current_html, hourly_html, daily_html)
- alerts_html: Carte HTML des alertes météo
- current_html: Carte HTML des conditions actuelles
- hourly_html: Carte HTML des prévisions horaires
- daily_html: Carte HTML des prévisions sur 10 jours
"""
# Récupérer les données météo formatées
alerts_info, current_conditions, hourly_forecast, daily_forecast = get_weather_forecast(address)
# Convertir en cartes HTML stylisées
alerts_html = format_alerts_card_html(alerts_info)
current_html = format_current_card_html(current_conditions)
hourly_html = format_hourly_card_html(hourly_forecast)
daily_html = format_daily_card_html(daily_forecast)
return alerts_html, current_html, hourly_html, daily_html
# Interface Gradio pour MCP avec cartes visuelles
def create_mcp_interface():
"""
Crée l'interface Gradio pour le serveur MCP avec cartes visuelles et fonctions individuelles.
Combine la belle interface avec cartes HTML et les fonctions MCP séparées :
- Interface web: Cartes visuelles avec dégradés et boussoles
- Fonctions MCP: 5 outils texte individuels pour Claude Desktop
Returns:
gr.Blocks: Interface Gradio hybride optimisée pour MCP et web
"""
# CSS personnalisé avec style Liquid Glass d'Apple
custom_css = """
:root {
/* Couleurs Apple */
--apple-blue: #0066CC;
--apple-shark: #1D1D1F;
--apple-gray: #F5F5F7;
--apple-white: #FFFFFF;
--system-blue: #007AFF;
--system-red: #FF3B30;
--system-orange: #FF9500;
--system-yellow: #FFCC00;
--system-green: #34C759;
/* Variables Liquid Glass */
--backdrop-blur: 20px;
--backdrop-brightness: 110%;
--glass-opacity: 0.15;
--glass-border-light: rgba(255, 255, 255, 0.2);
--glass-border-dark: rgba(255, 255, 255, 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
--glass-opacity: 0.1;
}
}
html, body, .gradio-container, .app, .main, #root {
max-width: 900px !important;
margin: 0 auto !important;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
min-height: 100vh !important;
}
/* Forcer le fond sur HuggingFace Spaces */
.gradio-app {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
}
/* Override Hugging Face Spaces default styles */
.app.svelte-182fdeq.svelte-182fdeq {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
}
.weather-card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.25),
rgba(255, 255, 255, 0.1)
);
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
border: 0.5px solid var(--glass-border-light);
border-radius: 20px;
padding: 20px;
margin: 8px 0;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
width: 100%;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.weather-card:hover {
backdrop-filter: blur(25px) saturate(200%);
-webkit-backdrop-filter: blur(25px) saturate(200%);
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px) translateZ(0);
box-shadow: 0 12px 40px rgba(31, 38, 135, 0.5);
}
.alert-card-red {
background: linear-gradient(
135deg,
rgba(255, 59, 48, var(--glass-opacity)),
rgba(255, 59, 48, 0.08)
);
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
border: 1px solid rgba(255, 59, 48, 0.3);
border-radius: 20px;
padding: 20px;
margin: 8px 0;
color: white;
box-shadow:
0 8px 32px 0 rgba(255, 59, 48, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
width: 100%;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.alert-card-orange {
background: linear-gradient(
135deg,
rgba(255, 149, 0, var(--glass-opacity)),
rgba(255, 149, 0, 0.08)
);
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
border: 1px solid rgba(255, 149, 0, 0.3);
border-radius: 20px;
padding: 20px;
margin: 8px 0;
color: white;
box-shadow:
0 8px 32px 0 rgba(255, 149, 0, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
width: 100%;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.alert-card-yellow {
background: linear-gradient(
135deg,
rgba(255, 204, 0, 0.3),
rgba(255, 204, 0, 0.15)
);
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
border: 1px solid rgba(255, 204, 0, 0.5);
border-radius: 20px;
padding: 20px;
margin: 8px 0;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
box-shadow:
0 8px 32px 0 rgba(255, 204, 0, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
width: 100%;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.current-card {
background: linear-gradient(
135deg,
rgba(0, 122, 255, var(--glass-opacity)),
rgba(0, 122, 255, 0.05)
);
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
border: 0.5px solid var(--glass-border-light);
border-radius: 20px;
padding: 20px;
margin: 8px 0;
color: white;
box-shadow:
0 8px 32px 0 rgba(0, 122, 255, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
width: 100%;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.hourly-card {
background: linear-gradient(
135deg,
rgba(175, 82, 222, var(--glass-opacity)),
rgba(175, 82, 222, 0.05)
);
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
border: 0.5px solid var(--glass-border-light);
border-radius: 20px;
padding: 20px;
margin: 8px 0;
color: white;
box-shadow:
0 8px 32px 0 rgba(175, 82, 222, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
width: 100%;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.daily-card {
background: linear-gradient(
135deg,
rgba(52, 199, 89, var(--glass-opacity)),
rgba(52, 199, 89, 0.05)
);
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%);
border: 0.5px solid var(--glass-border-light);
border-radius: 20px;
padding: 20px;
margin: 8px 0;
color: white;
box-shadow:
0 8px 32px 0 rgba(52, 199, 89, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
width: 100%;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.temp-large {
font-size: 2.5em;
font-weight: 700;
margin: 5px 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.hourly-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin: 4px 0;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 15px;
font-size: 0.9em;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.hourly-item:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-1px) translateZ(0);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.daily-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin: 4px 0;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 15px;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
transform: translateZ(0);
}
.daily-item:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-1px) translateZ(0);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Système adaptatif mode sombre */
@media (prefers-color-scheme: dark) {
body, .gradio-container {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%) !important;
}
.weather-card, .current-card, .hourly-card, .daily-card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.15),
rgba(255, 255, 255, 0.05)
);
border: 0.5px solid var(--glass-border-dark);
color: white;
}
.hourly-item, .daily-item {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
/* Styles spécifiques avec classes personnalisées */
.liquid-glass-button {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.25),
rgba(255, 255, 255, 0.1)
) !important;
backdrop-filter: blur(15px) saturate(180%) !important;
-webkit-backdrop-filter: blur(15px) saturate(180%) !important;
border: 0.5px solid rgba(255, 255, 255, 0.2) !important;
border-radius: 50px !important;
color: white !important;
font-weight: 500 !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5) !important;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1) !important;
transform: translateZ(0) !important;
box-shadow:
0 4px 16px rgba(31, 38, 135, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2) !important;
}
.liquid-glass-button:hover {
background: rgba(255, 255, 255, 0.35) !important;
backdrop-filter: blur(20px) saturate(200%) !important;
-webkit-backdrop-filter: blur(20px) saturate(200%) !important;
transform: translateY(-2px) translateZ(0) !important;
box-shadow: 0 8px 25px rgba(31, 38, 135, 0.5) !important;
}
.liquid-glass-textbox {
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.3),
rgba(118, 75, 162, 0.2)
) !important;
backdrop-filter: blur(20px) saturate(180%) !important;
-webkit-backdrop-filter: blur(20px) saturate(180%) !important;
border: 2px solid rgba(255, 255, 255, 0.4) !important;
border-radius: 20px !important;
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1) !important;
transform: translateZ(0) !important;
overflow: hidden !important;
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.3),
inset 0 -1px 0 rgba(0, 0, 0, 0.1) !important;
}
/* Ciblage spécifique basé sur le HTML généré */
.liquid-glass-textbox.svelte-1svsvh2,
.liquid-glass-textbox .container,
.liquid-glass-textbox .show_textbox_border,
.liquid-glass-textbox .svelte-173056l,
.liquid-glass-textbox .input-container,
.liquid-glass-textbox textarea.svelte-173056l {
border-radius: 20px !important;
overflow: hidden !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* Styles spécifiques pour le label container */
.liquid-glass-textbox .container.show_textbox_border {
border: none !important;
background: transparent !important;
border-radius: 20px !important;
}
/* Style pour le conteneur d'input */
.liquid-glass-textbox .input-container.svelte-173056l {
background: transparent !important;
border: none !important;
border-radius: 20px !important;
margin: 0 !important;
padding: 0 !important;
}
/* Application du clip-path sur l'élément racine pour forcer les coins */
.liquid-glass-textbox.svelte-1svsvh2.padded {
clip-path: inset(0 round 20px) !important;
-webkit-clip-path: inset(0 round 20px) !important;
overflow: hidden !important;
position: relative !important;
}
/* Masquage complet de tous les arrière-plans internes */
.liquid-glass-textbox * {
background-color: transparent !important;
background-image: none !important;
background: transparent !important;
}
/* Créer un pseudo-élément pour masquer les débordements */
.liquid-glass-textbox::after {
content: "" !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
border-radius: 20px !important;
pointer-events: none !important;
z-index: 1 !important;
clip-path: inset(0 round 20px) !important;
-webkit-clip-path: inset(0 round 20px) !important;
}
.liquid-glass-textbox input,
.liquid-glass-textbox textarea {
background: transparent !important;
color: white !important;
border: none !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5) !important;
padding: 16px 20px !important;
font-size: 16px !important;
font-weight: 400 !important;
}
.liquid-glass-textbox input::placeholder,
.liquid-glass-textbox textarea::placeholder {
color: rgba(255, 255, 255, 0.8) !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5) !important;
font-weight: 400 !important;
}
.liquid-glass-textbox:focus-within {
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.35),
rgba(118, 75, 162, 0.25)
) !important;
backdrop-filter: blur(25px) saturate(200%) !important;
-webkit-backdrop-filter: blur(25px) saturate(200%) !important;
border-color: rgba(255, 255, 255, 0.5) !important;
box-shadow:
0 12px 40px rgba(31, 38, 135, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important;
transform: translateY(-1px) translateZ(0) !important;
clip-path: inset(0 round 20px) !important;
-webkit-clip-path: inset(0 round 20px) !important;
}
/* Maintenir les coins arrondis sur les éléments au focus */
.liquid-glass-textbox:focus-within .container,
.liquid-glass-textbox:focus-within .show_textbox_border,
.liquid-glass-textbox:focus-within .svelte-173056l,
.liquid-glass-textbox:focus-within .input-container,
.liquid-glass-textbox:focus-within textarea.svelte-173056l,
.liquid-glass-textbox textarea:focus {
border-radius: 20px !important;
overflow: hidden !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
}
.liquid-glass-textbox label {
color: white !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7) !important;
font-weight: 600 !important;
font-size: 14px !important;
margin-bottom: 8px !important;
}
/* Style du crédit en bas de page */
.footer-credit {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.15),
rgba(255, 255, 255, 0.05)
) !important;
backdrop-filter: blur(15px) saturate(180%) !important;
-webkit-backdrop-filter: blur(15px) saturate(180%) !important;
border: 0.5px solid rgba(255, 255, 255, 0.2) !important;
border-radius: 15px !important;
color: white !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7) !important;
padding: 15px !important;
margin-top: 20px !important;
text-align: center !important;
font-size: 0.9em !important;
}
.footer-credit a {
color: rgba(255, 255, 255, 0.9) !important;
text-decoration: none !important;
font-weight: 600 !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7) !important;
}
.footer-credit a:hover {
color: white !important;
text-shadow: 0 0 8px rgba(255, 255, 255, 0.5) !important;
}
/* Réduction de transparence pour accessibilité */
@media (prefers-reduced-transparency: reduce) {
.weather-card, .current-card, .hourly-card, .daily-card,
.alert-card-red, .alert-card-orange, .alert-card-yellow {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.hourly-item, .daily-item {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
button, input[type="text"], textarea {
background: rgba(255, 255, 255, 0.9) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* Réduction mouvement pour accessibilité */
@media (prefers-reduced-motion: reduce) {
.weather-card, .current-card, .hourly-card, .daily-card,
.alert-card-red, .alert-card-orange, .alert-card-yellow,
.hourly-item, .daily-item, button, input[type="text"], textarea {
transition: none;
animation: none;
}
}
</style>
"""
# Thème personnalisé pour HuggingFace Spaces
theme = gr.themes.Soft(
primary_hue="blue",
secondary_hue="purple",
neutral_hue="slate"
).set(
body_background_fill="linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
background_fill_primary="transparent",
background_fill_secondary="transparent"
)
with gr.Blocks(
title="🌦️ Météo France - Serveur MCP",
theme=theme,
css=custom_css
) as interface:
gr.Markdown("# 🌦️ Météo France")
gr.Markdown("""
Prévisions météorologiques détaillées avec alertes
**5 fonctions MCP disponibles :**
`get_weather_alerts` | `get_current_weather` | `get_hourly_forecast` | `get_daily_forecast` | `get_complete_weather_forecast`
""")
# Interface principale avec cartes visuelles
with gr.Row():
address_input = gr.Textbox(
label="📍 Adresse",
placeholder="Entrez une adresse (ex: Paris, France)",
scale=3,
elem_classes=["liquid-glass-textbox"]
)
submit_btn = gr.Button("🔄 Actualiser", variant="primary", scale=1, elem_classes=["liquid-glass-button"])
# Alertes en premier (priorité haute)
with gr.Row():
alerts_output = gr.HTML(label="🚨 Alertes Météo")
# Conditions actuelles
with gr.Row():
current_output = gr.HTML(label="🌡️ Maintenant")
# Prévisions heure par heure
with gr.Row():
hourly_output = gr.HTML(label="🕐 Heure par Heure")
# Prévisions à 10 jours
with gr.Row():
daily_output = gr.HTML(label="📅 10 Jours")
# Connexions interface web (cartes HTML)
submit_btn.click(
fn=get_weather_forecast_html,
inputs=[address_input],
outputs=[alerts_output, current_output, hourly_output, daily_output]
)
address_input.submit(
fn=get_weather_forecast_html,
inputs=[address_input],
outputs=[alerts_output, current_output, hourly_output, daily_output]
)
# Boutons invisibles pour exposer les fonctions MCP
# Ces boutons créent les endpoints API nécessaires pour MCP
with gr.Row(visible=False):
mcp_input = gr.Textbox()
mcp_output = gr.Textbox()
alerts_mcp_btn = gr.Button("MCP Alerts")
current_mcp_btn = gr.Button("MCP Current")
hourly_mcp_btn = gr.Button("MCP Hourly")
daily_mcp_btn = gr.Button("MCP Daily")
complete_mcp_btn = gr.Button("MCP Complete")
# Connexions MCP (invisibles mais exposées via API)
alerts_mcp_btn.click(
fn=get_weather_alerts,
inputs=mcp_input,
outputs=mcp_output,
show_api=True,
api_name="get_weather_alerts"
)
current_mcp_btn.click(
fn=get_current_weather,
inputs=mcp_input,
outputs=mcp_output,
show_api=True,
api_name="get_current_weather"
)
hourly_mcp_btn.click(
fn=get_hourly_forecast,
inputs=mcp_input,
outputs=mcp_output,
show_api=True,
api_name="get_hourly_forecast"
)
daily_mcp_btn.click(
fn=get_daily_forecast,
inputs=mcp_input,
outputs=mcp_output,
show_api=True,
api_name="get_daily_forecast"
)
complete_mcp_btn.click(
fn=get_complete_weather_forecast,
inputs=mcp_input,
outputs=mcp_output,
show_api=True,
api_name="get_complete_weather_forecast"
)
# Crédit Météo-France obligatoire
with gr.Row():
gr.HTML("""
<div class="footer-credit">
📡 <strong>Données météorologiques</strong> fournies par
<a href="https://meteofrance.fr" target="_blank">
<strong>Météo-France</strong>
</a> |
🗺️ <strong>Géolocalisation</strong> par
<a href="https://geoservices.ign.fr" target="_blank">
<strong>IGN Géoplateforme</strong>
</a>
</div>
""")
return interface
if __name__ == "__main__":
interface = create_mcp_interface()
interface.launch(mcp_server=True)