Qdonnars Cursor commited on
Commit
aac4780
·
1 Parent(s): 3e8939b

MCP Server ADEME Environnement - Initial release

Browse files

Serveur MCP exposant les données environnementales ADEME:
- AGRIBALYSE: impacts alimentaires (2500+ produits)
- Base Carbone: facteurs d'émission (20000+ FE)

9 outils MCP:
- search_emission_factors: recherche unifiée 2 bases
- get_emission_factor_details: détails méthodologiques
- compare_emission_factors: comparaison avec sensibilité
- search_food, get_transport/energy/waste/equipment_factors
- get_categories

Co-authored-by: Cursor <cursoragent@cursor.com>

.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Configuration MCP Server ADEME Environnement
2
+ # Pas de clé API requise - les APIs ADEME sont publiques
3
+
4
+ # Optionnel : timeout des requêtes HTTP (secondes)
5
+ # HTTP_TIMEOUT=30
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .env
2
+ venv/
3
+ __pycache__/
4
+ *.pyc
README.md CHANGED
@@ -1,13 +1,154 @@
1
  ---
2
- title: MCP CO2
3
- emoji: 📚
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.5.1
8
  app_file: app.py
9
  pinned: false
10
- short_description: MCP to acces api of ADEME Base Carbone et la Base Agribalyse
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MCP Server ADEME Environnement
3
+ emoji: 🌱
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 5.29.0
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
 
12
+ # 🌱 MCP Server ADEME Environnement
13
+
14
+ Serveur MCP (Model Context Protocol) pour accéder aux données environnementales de l'ADEME.
15
+
16
+ ## 📊 Sources de données
17
+
18
+ | Base | Description | Données |
19
+ |------|-------------|---------|
20
+ | **AGRIBALYSE** | Impacts environnementaux alimentaires | 2500+ produits, 16 indicateurs |
21
+ | **Base Carbone** | Facteurs d'émission GES | 20000+ FE (transport, énergie, déchets, équipements) |
22
+
23
+ ## 🛠️ Outils MCP disponibles
24
+
25
+ ### Recherche
26
+
27
+ | Outil | Description |
28
+ |-------|-------------|
29
+ | `search_emission_factors` | Recherche unifiée dans AGRIBALYSE + Base Carbone |
30
+ | `get_emission_factor_details` | Détails méthodologiques complets d'un FE |
31
+ | `compare_emission_factors` | Comparaison de 2 FE avec analyse de sensibilité |
32
+ | `get_categories` | Liste des catégories disponibles |
33
+
34
+ ### Par domaine
35
+
36
+ | Outil | Source | Description |
37
+ |-------|--------|-------------|
38
+ | `search_food` | AGRIBALYSE | Recherche d'aliments avec impacts environnementaux |
39
+ | `get_transport_factors` | Base Carbone | FE transport (véhicule, carburant) |
40
+ | `get_energy_factors` | Base Carbone | FE énergie (gaz, électricité, fioul) |
41
+ | `get_waste_factors` | Base Carbone | FE déchets (par type et traitement) |
42
+ | `get_equipment_factors` | Base Carbone | FE équipements (informatique, électroménager) |
43
+
44
+ ## 🔌 Connexion MCP
45
+
46
+ ### Configuration Claude Desktop
47
+
48
+ Ajouter dans `claude_desktop_config.json` :
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "ademe-environnement": {
54
+ "url": "https://VOTRE-USERNAME-mcp-ademe-environnement.hf.space/gradio_api/mcp/sse"
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### Configuration Cursor
61
+
62
+ Ajouter dans les paramètres MCP :
63
+
64
+ ```json
65
+ {
66
+ "ademe-environnement": {
67
+ "url": "https://VOTRE-USERNAME-mcp-ademe-environnement.hf.space/gradio_api/mcp/sse"
68
+ }
69
+ }
70
+ ```
71
+
72
+ ## 📖 Exemples d'utilisation
73
+
74
+ ### Recherche unifiée
75
+
76
+ ```
77
+ search_emission_factors("boeuf")
78
+ → Résultats AGRIBALYSE (viande) + Base Carbone (élevage)
79
+ ```
80
+
81
+ ### Analyse de sensibilité transport
82
+
83
+ ```
84
+ # Trouver les FE
85
+ get_transport_factors("VUL", "diesel")
86
+ get_transport_factors("VUL", "électrique")
87
+
88
+ # Comparer
89
+ compare_emission_factors("28022", "28024")
90
+ → Écart, incertitude, recommandation
91
+ ```
92
+
93
+ ### Bilan repas cantine
94
+
95
+ ```
96
+ # Aliments
97
+ search_food("poulet rôti")
98
+ search_food("haricots verts")
99
+
100
+ # Transport
101
+ get_transport_factors("VUL", "diesel")
102
+
103
+ # Énergie cuisson
104
+ get_energy_factors("gaz naturel")
105
+
106
+ # Déchets
107
+ get_waste_factors("alimentaire", "compost")
108
+ ```
109
+
110
+ ## 📁 Structure du projet
111
+
112
+ ```
113
+ mcp-ademe-environnement/
114
+ ├── app.py # Point d'entrée Gradio + MCP
115
+ ├── requirements.txt # Dépendances
116
+ ├── README.md # Ce fichier
117
+ └── src/
118
+ ├── __init__.py
119
+ ├── models.py # Modèles Pydantic
120
+ ├── tools.py # Outils MCP
121
+ └── clients/
122
+ ├── __init__.py
123
+ ├── agribalyse.py # Client API AGRIBALYSE
124
+ └── base_carbone.py # Client API Base Carbone
125
+ ```
126
+
127
+ ## 🚀 Déploiement
128
+
129
+ ### Local
130
+
131
+ ```bash
132
+ pip install -r requirements.txt
133
+ python app.py
134
+ ```
135
+
136
+ ### HuggingFace Spaces
137
+
138
+ 1. Créer un nouveau Space (SDK: Gradio)
139
+ 2. Pousser le code
140
+ 3. Le Space démarre automatiquement
141
+
142
+ ## 📚 Documentation des APIs
143
+
144
+ - [AGRIBALYSE](https://agribalyse.ademe.fr/) - Documentation officielle
145
+ - [Base Carbone](https://base-empreinte.ademe.fr/) - Base Empreinte ADEME
146
+ - [data.ademe.fr](https://data.ademe.fr/) - Portail open data ADEME
147
+
148
+ ## 📄 Licence
149
+
150
+ Données sous [Licence Ouverte / Open Licence](https://www.etalab.gouv.fr/licence-ouverte-open-licence/)
151
+
152
+ ---
153
+
154
+ Développé avec ❤️ pour faciliter l'accès aux données environnementales
app.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP Server ADEME Environnement - Point d'entrée Gradio.
2
+
3
+ Ce serveur MCP expose les données environnementales de l'ADEME :
4
+ - AGRIBALYSE : impacts environnementaux de 2500+ aliments
5
+ - Base Carbone : 20000+ facteurs d'émission (transport, énergie, déchets, équipements)
6
+
7
+ Utilisation :
8
+ - Déployer sur HuggingFace Spaces
9
+ - Connecter depuis un client MCP (Claude, etc.)
10
+ """
11
+
12
+ import gradio as gr
13
+
14
+ from src.tools import (
15
+ search_emission_factors,
16
+ get_emission_factor_details,
17
+ compare_emission_factors,
18
+ search_food,
19
+ get_transport_factors,
20
+ get_energy_factors,
21
+ get_waste_factors,
22
+ get_equipment_factors,
23
+ get_categories,
24
+ )
25
+
26
+
27
+ # Créer les interfaces pour chaque outil MCP
28
+ # Chaque gr.Interface expose la fonction comme outil MCP
29
+
30
+ search_interface = gr.Interface(
31
+ fn=search_emission_factors,
32
+ inputs=[
33
+ gr.Textbox(label="query", info="Terme de recherche (ex: boeuf, camion diesel)"),
34
+ gr.Dropdown(choices=["all", "agribalyse", "base_carbone"], value="all", label="source"),
35
+ gr.Number(value=10, label="size", info="Nombre de résultats"),
36
+ ],
37
+ outputs=gr.JSON(label="Résultats"),
38
+ title="search_emission_factors",
39
+ description="Recherche unifiée de facteurs d'émission dans AGRIBALYSE et Base Carbone",
40
+ )
41
+
42
+ details_interface = gr.Interface(
43
+ fn=get_emission_factor_details,
44
+ inputs=[
45
+ gr.Textbox(label="identifier", info="ID du facteur d'émission"),
46
+ gr.Dropdown(choices=["base_carbone", "agribalyse"], value="base_carbone", label="source"),
47
+ ],
48
+ outputs=gr.JSON(label="Détails"),
49
+ title="get_emission_factor_details",
50
+ description="Détails méthodologiques complets d'un facteur d'émission",
51
+ )
52
+
53
+ compare_interface = gr.Interface(
54
+ fn=compare_emission_factors,
55
+ inputs=[
56
+ gr.Textbox(label="id1", info="ID du premier FE"),
57
+ gr.Textbox(label="id2", info="ID du second FE"),
58
+ gr.Dropdown(choices=["base_carbone", "agribalyse"], value="base_carbone", label="source1"),
59
+ gr.Dropdown(choices=["base_carbone", "agribalyse"], value="base_carbone", label="source2"),
60
+ ],
61
+ outputs=gr.JSON(label="Comparaison"),
62
+ title="compare_emission_factors",
63
+ description="Compare deux facteurs d'émission avec analyse de sensibilité",
64
+ )
65
+
66
+ food_interface = gr.Interface(
67
+ fn=search_food,
68
+ inputs=[
69
+ gr.Textbox(label="query", info="Terme de recherche (ex: steak, yaourt)"),
70
+ gr.Textbox(label="group", info="Groupe d'aliment (optionnel)"),
71
+ gr.Number(value=10, label="size"),
72
+ ],
73
+ outputs=gr.JSON(label="Résultats"),
74
+ title="search_food",
75
+ description="Recherche d'aliments dans la base AGRIBALYSE",
76
+ )
77
+
78
+ transport_interface = gr.Interface(
79
+ fn=get_transport_factors,
80
+ inputs=[
81
+ gr.Textbox(label="vehicle_type", info="Type de véhicule (ex: VUL, poids lourd)"),
82
+ gr.Textbox(label="fuel_type", info="Carburant (ex: diesel, électrique)"),
83
+ gr.Number(value=20, label="size"),
84
+ ],
85
+ outputs=gr.JSON(label="Résultats"),
86
+ title="get_transport_factors",
87
+ description="Recherche de facteurs d'émission pour le transport",
88
+ )
89
+
90
+ energy_interface = gr.Interface(
91
+ fn=get_energy_factors,
92
+ inputs=[
93
+ gr.Textbox(label="energy_type", info="Type d'énergie (ex: gaz naturel, électricité)"),
94
+ gr.Number(value=20, label="size"),
95
+ ],
96
+ outputs=gr.JSON(label="Résultats"),
97
+ title="get_energy_factors",
98
+ description="Recherche de facteurs d'émission pour l'énergie",
99
+ )
100
+
101
+ waste_interface = gr.Interface(
102
+ fn=get_waste_factors,
103
+ inputs=[
104
+ gr.Textbox(label="waste_type", info="Type de déchet (ex: alimentaire, plastique)"),
105
+ gr.Textbox(label="treatment", info="Traitement (ex: compost, incinération)"),
106
+ gr.Number(value=20, label="size"),
107
+ ],
108
+ outputs=gr.JSON(label="Résultats"),
109
+ title="get_waste_factors",
110
+ description="Recherche de facteurs d'émission pour les déchets",
111
+ )
112
+
113
+ equipment_interface = gr.Interface(
114
+ fn=get_equipment_factors,
115
+ inputs=[
116
+ gr.Textbox(label="equipment_type", info="Type d'équipement (ex: ordinateur, smartphone)"),
117
+ gr.Number(value=20, label="size"),
118
+ ],
119
+ outputs=gr.JSON(label="Résultats"),
120
+ title="get_equipment_factors",
121
+ description="Recherche de facteurs d'émission pour les équipements",
122
+ )
123
+
124
+ categories_interface = gr.Interface(
125
+ fn=get_categories,
126
+ inputs=[
127
+ gr.Dropdown(choices=["all", "agribalyse", "base_carbone"], value="all", label="source"),
128
+ ],
129
+ outputs=gr.JSON(label="Catégories"),
130
+ title="get_categories",
131
+ description="Liste les catégories disponibles dans les bases de données",
132
+ )
133
+
134
+ # Combiner toutes les interfaces dans des onglets
135
+ demo = gr.TabbedInterface(
136
+ interface_list=[
137
+ search_interface,
138
+ food_interface,
139
+ transport_interface,
140
+ energy_interface,
141
+ waste_interface,
142
+ equipment_interface,
143
+ compare_interface,
144
+ details_interface,
145
+ categories_interface,
146
+ ],
147
+ tab_names=[
148
+ "🔍 Recherche",
149
+ "🍎 Alimentation",
150
+ "🚗 Transport",
151
+ "⚡ Énergie",
152
+ "🗑️ Déchets",
153
+ "💻 Équipements",
154
+ "⚖️ Comparer",
155
+ "📋 Détails",
156
+ "📂 Catégories",
157
+ ],
158
+ title="🌱 MCP Server ADEME Environnement",
159
+ )
160
+
161
+ # Lancement avec MCP activé
162
+ if __name__ == "__main__":
163
+ demo.launch(mcp_server=True)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio[mcp]>=5.27.1
2
+ httpx>=0.27.0
3
+ pydantic>=2.0.0
4
+ python-dotenv>=1.0.0
5
+ anyio>=4.0.0
src/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # MCP Server ADEME Environnement
2
+ # Intégration AGRIBALYSE + Base Carbone
3
+
4
+ __version__ = "1.0.0"
src/clients/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .agribalyse import AgribalyseClient
2
+ from .base_carbone import BaseCarboneClient
3
+
4
+ __all__ = ["AgribalyseClient", "BaseCarboneClient"]
src/clients/agribalyse.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client API AGRIBALYSE - Données environnementales alimentaires."""
2
+
3
+ import httpx
4
+ from typing import Any
5
+
6
+ BASE_URL = "https://data.ademe.fr/data-fair/api/v1/datasets/agribalyse-31-synthese"
7
+
8
+
9
+ class AgribalyseClient:
10
+ """Client async pour l'API AGRIBALYSE."""
11
+
12
+ def __init__(self, timeout: float = 30.0):
13
+ self.timeout = timeout
14
+ self._client: httpx.AsyncClient | None = None
15
+
16
+ async def _get_client(self) -> httpx.AsyncClient:
17
+ if self._client is None:
18
+ self._client = httpx.AsyncClient(timeout=self.timeout)
19
+ return self._client
20
+
21
+ async def close(self):
22
+ if self._client:
23
+ await self._client.aclose()
24
+ self._client = None
25
+
26
+ async def search(
27
+ self,
28
+ query: str,
29
+ size: int = 10,
30
+ select: list[str] | None = None
31
+ ) -> dict[str, Any]:
32
+ """Recherche d'aliments dans AGRIBALYSE.
33
+
34
+ Args:
35
+ query: Terme de recherche (ex: "boeuf", "pomme")
36
+ size: Nombre de résultats (max 100)
37
+ select: Champs à retourner (None = tous)
38
+
39
+ Returns:
40
+ Dict avec total et results
41
+ """
42
+ client = await self._get_client()
43
+
44
+ params = {
45
+ "q": query,
46
+ "size": min(size, 100)
47
+ }
48
+
49
+ if select:
50
+ params["select"] = ",".join(select)
51
+
52
+ response = await client.get(f"{BASE_URL}/lines", params=params)
53
+ response.raise_for_status()
54
+ return response.json()
55
+
56
+ async def get_by_code(self, code_ciqual: int) -> dict[str, Any] | None:
57
+ """Récupère un aliment par son code CIQUAL.
58
+
59
+ Args:
60
+ code_ciqual: Code CIQUAL de l'aliment
61
+
62
+ Returns:
63
+ Données de l'aliment ou None
64
+ """
65
+ client = await self._get_client()
66
+
67
+ params = {
68
+ "qs": f"Code_CIQUAL:{code_ciqual}",
69
+ "size": 1
70
+ }
71
+
72
+ response = await client.get(f"{BASE_URL}/lines", params=params)
73
+ response.raise_for_status()
74
+ data = response.json()
75
+
76
+ if data.get("results"):
77
+ return data["results"][0]
78
+ return None
79
+
80
+ async def get_categories(self) -> list[str]:
81
+ """Liste les groupes d'aliments disponibles.
82
+
83
+ Returns:
84
+ Liste des groupes d'aliments uniques
85
+ """
86
+ client = await self._get_client()
87
+
88
+ response = await client.get(
89
+ f"{BASE_URL}/values_agg",
90
+ params={"field": "Groupe_d'aliment", "size": 100}
91
+ )
92
+ response.raise_for_status()
93
+ data = response.json()
94
+
95
+ return [item["value"] for item in data.get("aggs", [])]
96
+
97
+ async def get_subcategories(self, group: str) -> list[str]:
98
+ """Liste les sous-groupes d'un groupe d'aliments.
99
+
100
+ Args:
101
+ group: Nom du groupe d'aliments
102
+
103
+ Returns:
104
+ Liste des sous-groupes
105
+ """
106
+ client = await self._get_client()
107
+
108
+ response = await client.get(
109
+ f"{BASE_URL}/values_agg",
110
+ params={
111
+ "field": "Sous-groupe_d'aliment",
112
+ "size": 100,
113
+ "qs": f"Groupe_d'aliment:\"{group}\""
114
+ }
115
+ )
116
+ response.raise_for_status()
117
+ data = response.json()
118
+
119
+ return [item["value"] for item in data.get("aggs", [])]
120
+
121
+
122
+ # Champs principaux AGRIBALYSE pour référence
123
+ AGRIBALYSE_FIELDS = {
124
+ "identite": [
125
+ "Code_CIQUAL",
126
+ "Code_AGB",
127
+ "Nom_du_Produit_en_Français",
128
+ "LCI_Name",
129
+ "Groupe_d'aliment",
130
+ "Sous-groupe_d'aliment",
131
+ ],
132
+ "impact_climatique": [
133
+ "Changement_climatique",
134
+ "Changement_climatique_-_émissions_fossiles",
135
+ "Changement_climatique_-_émissions_biogéniques",
136
+ "Changement_climatique_-_émissions_liées_au_changement_d'affectation_des_sols",
137
+ ],
138
+ "autres_impacts": [
139
+ "Score_unique_EF",
140
+ "Acidification_terrestre_et_eaux_douces",
141
+ "Eutrophisation_marine",
142
+ "Eutrophisation_eaux_douces",
143
+ "Eutrophisation_terrestre",
144
+ "Utilisation_du_sol",
145
+ "Épuisement_des_ressources_eau",
146
+ "Épuisement_des_ressources_énergétiques",
147
+ "Épuisement_des_ressources_minéraux",
148
+ "Particules_fines",
149
+ "Formation_photochimique_d'ozone",
150
+ "Appauvrissement_de_la_couche_d'ozone",
151
+ "Rayonnements_ionisants",
152
+ "Écotoxicité_pour_écosystèmes_aquatiques_d'eau_douce",
153
+ "Effets_toxicologiques_sur_la_santé_humaine___substances_cancérogènes",
154
+ "Effets_toxicologiques_sur_la_santé_humaine___substances_non-cancérogènes",
155
+ ],
156
+ "qualite": [
157
+ "DQR",
158
+ ],
159
+ "contexte": [
160
+ "Livraison",
161
+ "Préparation",
162
+ "Approche_emballage_",
163
+ "code_avion",
164
+ "code_saison",
165
+ ],
166
+ }
src/clients/base_carbone.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client API Base Carbone - Facteurs d'émission ADEME."""
2
+
3
+ import httpx
4
+ from typing import Any
5
+
6
+ BASE_URL = "https://data.ademe.fr/data-fair/api/v1/datasets/base-carboner"
7
+
8
+
9
+ class BaseCarboneClient:
10
+ """Client async pour l'API Base Carbone."""
11
+
12
+ def __init__(self, timeout: float = 30.0):
13
+ self.timeout = timeout
14
+ self._client: httpx.AsyncClient | None = None
15
+
16
+ async def _get_client(self) -> httpx.AsyncClient:
17
+ if self._client is None:
18
+ self._client = httpx.AsyncClient(timeout=self.timeout)
19
+ return self._client
20
+
21
+ async def close(self):
22
+ if self._client:
23
+ await self._client.aclose()
24
+ self._client = None
25
+
26
+ async def search(
27
+ self,
28
+ query: str,
29
+ size: int = 10,
30
+ type_ligne: str = "Elément",
31
+ statut: str | None = None,
32
+ category: str | None = None
33
+ ) -> dict[str, Any]:
34
+ """Recherche de facteurs d'émission dans Base Carbone.
35
+
36
+ Args:
37
+ query: Terme de recherche
38
+ size: Nombre de résultats (max 100)
39
+ type_ligne: "Elément" ou "Poste" (défaut: Elément)
40
+ statut: Filtrer par statut ("Valide générique", "Archivé", etc.)
41
+ category: Filtrer par catégorie (chemin partiel)
42
+
43
+ Returns:
44
+ Dict avec total et results
45
+ """
46
+ client = await self._get_client()
47
+
48
+ params = {
49
+ "q": query,
50
+ "size": min(size, 100)
51
+ }
52
+
53
+ # Construire les filtres
54
+ filters = []
55
+ if type_ligne:
56
+ filters.append(f"Type_Ligne:{type_ligne}")
57
+ if statut:
58
+ filters.append(f"Statut_de_l'élément:\"{statut}\"")
59
+ if category:
60
+ filters.append(f"Code_de_la_catégorie:*{category}*")
61
+
62
+ if filters:
63
+ params["qs"] = " AND ".join(filters)
64
+
65
+ response = await client.get(f"{BASE_URL}/lines", params=params)
66
+ response.raise_for_status()
67
+ return response.json()
68
+
69
+ async def get_by_id(self, element_id: str) -> dict[str, Any] | None:
70
+ """Récupère un facteur d'émission par son identifiant.
71
+
72
+ Args:
73
+ element_id: Identifiant de l'élément
74
+
75
+ Returns:
76
+ Données du FE ou None
77
+ """
78
+ client = await self._get_client()
79
+
80
+ params = {
81
+ "qs": f"Identifiant_de_l'élément:{element_id}",
82
+ "size": 10 # Peut avoir plusieurs lignes (postes)
83
+ }
84
+
85
+ response = await client.get(f"{BASE_URL}/lines", params=params)
86
+ response.raise_for_status()
87
+ data = response.json()
88
+
89
+ if data.get("results"):
90
+ return data["results"]
91
+ return None
92
+
93
+ async def get_categories(self, parent: str | None = None) -> list[dict[str, Any]]:
94
+ """Liste les catégories disponibles.
95
+
96
+ Args:
97
+ parent: Chemin de catégorie parent pour filtrer
98
+
99
+ Returns:
100
+ Liste des catégories avec leur compte
101
+ """
102
+ client = await self._get_client()
103
+
104
+ params = {
105
+ "field": "Code_de_la_catégorie",
106
+ "size": 200
107
+ }
108
+
109
+ if parent:
110
+ params["qs"] = f"Code_de_la_catégorie:*{parent}*"
111
+
112
+ response = await client.get(f"{BASE_URL}/values_agg", params=params)
113
+ response.raise_for_status()
114
+ data = response.json()
115
+
116
+ return [
117
+ {"category": item["value"], "count": item["total"]}
118
+ for item in data.get("aggs", [])
119
+ ]
120
+
121
+ async def search_transport(
122
+ self,
123
+ vehicle_type: str | None = None,
124
+ fuel_type: str | None = None,
125
+ size: int = 20
126
+ ) -> dict[str, Any]:
127
+ """Recherche de FE transport.
128
+
129
+ Args:
130
+ vehicle_type: Type de véhicule (VUL, Poids lourd, Voiture, etc.)
131
+ fuel_type: Type de carburant (Diesel, Essence, Électrique, etc.)
132
+ size: Nombre de résultats
133
+
134
+ Returns:
135
+ Facteurs d'émission transport
136
+ """
137
+ query_parts = ["transport"]
138
+ if vehicle_type:
139
+ query_parts.append(vehicle_type)
140
+ if fuel_type:
141
+ query_parts.append(fuel_type)
142
+
143
+ return await self.search(
144
+ query=" ".join(query_parts),
145
+ size=size,
146
+ category="Transport"
147
+ )
148
+
149
+ async def search_energy(
150
+ self,
151
+ energy_type: str | None = None,
152
+ size: int = 20
153
+ ) -> dict[str, Any]:
154
+ """Recherche de FE énergie.
155
+
156
+ Args:
157
+ energy_type: Type d'énergie (Gaz naturel, Électricité, Fioul, etc.)
158
+ size: Nombre de résultats
159
+
160
+ Returns:
161
+ Facteurs d'émission énergie
162
+ """
163
+ query = energy_type or "énergie"
164
+
165
+ return await self.search(
166
+ query=query,
167
+ size=size,
168
+ category="Combustibles"
169
+ )
170
+
171
+ async def search_waste(
172
+ self,
173
+ waste_type: str | None = None,
174
+ treatment: str | None = None,
175
+ size: int = 20
176
+ ) -> dict[str, Any]:
177
+ """Recherche de FE déchets.
178
+
179
+ Args:
180
+ waste_type: Type de déchet (alimentaire, plastique, etc.)
181
+ treatment: Traitement (compost, incinération, stockage, etc.)
182
+ size: Nombre de résultats
183
+
184
+ Returns:
185
+ Facteurs d'émission déchets
186
+ """
187
+ query_parts = ["déchet"]
188
+ if waste_type:
189
+ query_parts.append(waste_type)
190
+ if treatment:
191
+ query_parts.append(treatment)
192
+
193
+ return await self.search(
194
+ query=" ".join(query_parts),
195
+ size=size,
196
+ category="Traitement des déchets"
197
+ )
198
+
199
+ async def search_equipment(
200
+ self,
201
+ equipment_type: str | None = None,
202
+ size: int = 20
203
+ ) -> dict[str, Any]:
204
+ """Recherche de FE équipements.
205
+
206
+ Args:
207
+ equipment_type: Type d'équipement (ordinateur, téléphone, etc.)
208
+ size: Nombre de résultats
209
+
210
+ Returns:
211
+ Facteurs d'émission équipements
212
+ """
213
+ query = equipment_type or "équipement informatique"
214
+
215
+ return await self.search(
216
+ query=query,
217
+ size=size,
218
+ category="Achats de biens"
219
+ )
220
+
221
+
222
+ # Champs principaux Base Carbone pour référence
223
+ BASE_CARBONE_FIELDS = {
224
+ "identite": [
225
+ "Identifiant_de_l'élément",
226
+ "Nom_base_français",
227
+ "Nom_base_anglais",
228
+ "Nom_attribut_français",
229
+ "Nom_frontière_français",
230
+ "Code_de_la_catégorie",
231
+ "Type_de_l'élément",
232
+ "Type_Ligne",
233
+ ],
234
+ "valeurs": [
235
+ "Total_poste_non_décomposé",
236
+ "Unité_français",
237
+ "Unité_anglais",
238
+ ],
239
+ "decomposition_gaz": [
240
+ "CO2f",
241
+ "CO2b",
242
+ "CH4f",
243
+ "CH4b",
244
+ "N2O",
245
+ "Autres_GES",
246
+ ],
247
+ "qualite": [
248
+ "Incertitude",
249
+ "Qualité",
250
+ "Qualité_M",
251
+ "Qualité_TeR",
252
+ "Qualité_C",
253
+ "Qualité_TiR",
254
+ "Qualité_GR",
255
+ "Qualité_P",
256
+ "Transparence",
257
+ ],
258
+ "methodologie": [
259
+ "Source",
260
+ "Programme",
261
+ "Url_du_programme",
262
+ "Contributeur",
263
+ "Autres_Contributeurs",
264
+ "Structure",
265
+ "Commentaire_français",
266
+ "Réglementations",
267
+ ],
268
+ "validite": [
269
+ "Statut_de_l'élément",
270
+ "Date_de_création",
271
+ "Date_de_modification",
272
+ "Période_de_validité",
273
+ ],
274
+ "localisation": [
275
+ "Localisation_géographique",
276
+ "Sous-localisation_géographique_français",
277
+ ],
278
+ }
src/models.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modèles Pydantic pour le MCP Server ADEME."""
2
+
3
+ from pydantic import BaseModel, Field
4
+ from typing import Any
5
+
6
+
7
+ class EmissionFactor(BaseModel):
8
+ """Facteur d'émission unifié (AGRIBALYSE ou Base Carbone)."""
9
+
10
+ source: str = Field(description="Source: 'agribalyse' ou 'base_carbone'")
11
+ id: str = Field(description="Identifiant unique")
12
+ name: str = Field(description="Nom en français")
13
+ value: float = Field(description="Valeur du FE en kgCO2e")
14
+ unit: str = Field(description="Unité (kgCO2e/kg, kgCO2e/km, etc.)")
15
+ category: str = Field(description="Catégorie")
16
+
17
+ # Qualité
18
+ uncertainty: float | None = Field(default=None, description="Incertitude en %")
19
+ dqr: float | None = Field(default=None, description="Data Quality Rating (1-5)")
20
+
21
+ # Métadonnées
22
+ methodology_source: str | None = Field(default=None, description="Source méthodologique")
23
+ validity_period: str | None = Field(default=None, description="Période de validité")
24
+ status: str | None = Field(default=None, description="Statut (Valide, Archivé)")
25
+
26
+
27
+ class FoodItem(BaseModel):
28
+ """Aliment AGRIBALYSE avec impacts environnementaux."""
29
+
30
+ code_ciqual: int = Field(description="Code CIQUAL")
31
+ code_agb: str = Field(description="Code AGRIBALYSE")
32
+ name_fr: str = Field(description="Nom en français")
33
+ name_en: str | None = Field(default=None, description="Nom en anglais")
34
+
35
+ group: str = Field(description="Groupe d'aliment")
36
+ subgroup: str = Field(description="Sous-groupe d'aliment")
37
+
38
+ # Impact climatique
39
+ climate_change: float = Field(description="Impact changement climatique (kgCO2e/kg)")
40
+ climate_change_fossil: float | None = Field(default=None, description="Émissions fossiles")
41
+ climate_change_biogenic: float | None = Field(default=None, description="Émissions biogéniques")
42
+ climate_change_land_use: float | None = Field(default=None, description="Changement d'affectation des sols")
43
+
44
+ # Score global
45
+ ef_score: float | None = Field(default=None, description="Score unique EF")
46
+ dqr: float | None = Field(default=None, description="Data Quality Rating")
47
+
48
+ # Contexte
49
+ delivery: str | None = Field(default=None, description="Mode de livraison")
50
+ preparation: str | None = Field(default=None, description="Mode de préparation")
51
+ packaging: str | None = Field(default=None, description="Type d'emballage")
52
+ by_plane: bool = Field(default=False, description="Transport par avion")
53
+
54
+
55
+ class TransportFactor(BaseModel):
56
+ """Facteur d'émission transport."""
57
+
58
+ id: str
59
+ vehicle_type: str = Field(description="Type de véhicule")
60
+ fuel_type: str | None = Field(default=None, description="Type de carburant")
61
+ attribute: str | None = Field(default=None, description="Attribut (tonnage, etc.)")
62
+
63
+ value: float = Field(description="Valeur en kgCO2e")
64
+ unit: str = Field(description="Unité (kgCO2e/km, kgCO2e/t.km, etc.)")
65
+
66
+ uncertainty: float | None = Field(default=None, description="Incertitude %")
67
+ source: str | None = Field(default=None, description="Source")
68
+ status: str | None = Field(default=None, description="Statut")
69
+
70
+
71
+ class EnergyFactor(BaseModel):
72
+ """Facteur d'émission énergie."""
73
+
74
+ id: str
75
+ energy_type: str = Field(description="Type d'énergie")
76
+ specification: str | None = Field(default=None, description="Spécification")
77
+
78
+ value: float = Field(description="Valeur en kgCO2e")
79
+ unit: str = Field(description="Unité (kgCO2e/kWh, kgCO2e/kg, etc.)")
80
+
81
+ # Décomposition par gaz
82
+ co2_fossil: float | None = Field(default=None, description="CO2 fossile")
83
+ co2_biogenic: float | None = Field(default=None, description="CO2 biogénique")
84
+ ch4: float | None = Field(default=None, description="Méthane")
85
+ n2o: float | None = Field(default=None, description="Protoxyde d'azote")
86
+
87
+ uncertainty: float | None = Field(default=None, description="Incertitude %")
88
+ source: str | None = Field(default=None, description="Source")
89
+
90
+
91
+ class WasteFactor(BaseModel):
92
+ """Facteur d'émission déchets."""
93
+
94
+ id: str
95
+ waste_type: str = Field(description="Type de déchet")
96
+ treatment: str = Field(description="Traitement (compost, incinération, etc.)")
97
+
98
+ value: float = Field(description="Valeur en kgCO2e/tonne")
99
+ unit: str = Field(default="kgCO2e/tonne", description="Unité")
100
+
101
+ # Décomposition par gaz
102
+ co2_fossil: float | None = None
103
+ co2_biogenic: float | None = None
104
+ ch4: float | None = None
105
+ n2o: float | None = None
106
+
107
+ uncertainty: float | None = None
108
+ source: str | None = None
109
+
110
+
111
+ class ComparisonResult(BaseModel):
112
+ """Résultat de comparaison entre 2 facteurs d'émission."""
113
+
114
+ factor1: EmissionFactor
115
+ factor2: EmissionFactor
116
+
117
+ absolute_difference: float = Field(description="Différence absolue (FE2 - FE1)")
118
+ relative_difference: float = Field(description="Différence relative en %")
119
+
120
+ # Analyse
121
+ lower_bound_diff: float | None = Field(
122
+ default=None,
123
+ description="Différence min compte tenu des incertitudes"
124
+ )
125
+ upper_bound_diff: float | None = Field(
126
+ default=None,
127
+ description="Différence max compte tenu des incertitudes"
128
+ )
129
+
130
+ recommendation: str | None = Field(
131
+ default=None,
132
+ description="Recommandation d'usage"
133
+ )
134
+
135
+
136
+ class SearchResult(BaseModel):
137
+ """Résultat de recherche unifiée."""
138
+
139
+ total: int = Field(description="Nombre total de résultats")
140
+ results: list[EmissionFactor] = Field(description="Facteurs d'émission trouvés")
141
+ sources: dict[str, int] = Field(
142
+ description="Nombre de résultats par source"
143
+ )
src/tools.py ADDED
@@ -0,0 +1,644 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Outils MCP pour l'API ADEME (AGRIBALYSE + Base Carbone)."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .clients import AgribalyseClient, BaseCarboneClient
7
+
8
+ # Clients globaux (initialisés au premier appel)
9
+ _agribalyse_client: AgribalyseClient | None = None
10
+ _base_carbone_client: BaseCarboneClient | None = None
11
+
12
+
13
+ def _get_agribalyse() -> AgribalyseClient:
14
+ global _agribalyse_client
15
+ if _agribalyse_client is None:
16
+ _agribalyse_client = AgribalyseClient()
17
+ return _agribalyse_client
18
+
19
+
20
+ def _get_base_carbone() -> BaseCarboneClient:
21
+ global _base_carbone_client
22
+ if _base_carbone_client is None:
23
+ _base_carbone_client = BaseCarboneClient()
24
+ return _base_carbone_client
25
+
26
+
27
+ def _format_agribalyse_result(item: dict[str, Any]) -> dict[str, Any]:
28
+ """Formate un résultat AGRIBALYSE en format unifié."""
29
+ return {
30
+ "source": "agribalyse",
31
+ "id": str(item.get("Code_CIQUAL", "")),
32
+ "name": item.get("Nom_du_Produit_en_Français", ""),
33
+ "name_en": item.get("LCI_Name", ""),
34
+ "value": item.get("Changement_climatique", 0),
35
+ "unit": "kgCO2e/kg",
36
+ "category": item.get("Groupe_d'aliment", ""),
37
+ "subcategory": item.get("Sous-groupe_d'aliment", ""),
38
+ "dqr": item.get("DQR"),
39
+ "ef_score": item.get("Score_unique_EF"),
40
+ "climate_fossil": item.get("Changement_climatique_-_émissions_fossiles"),
41
+ "climate_biogenic": item.get("Changement_climatique_-_émissions_biogéniques"),
42
+ "climate_land_use": item.get("Changement_climatique_-_émissions_liées_au_changement_d'affectation_des_sols"),
43
+ "delivery": item.get("Livraison"),
44
+ "preparation": item.get("Préparation"),
45
+ "packaging": item.get("Approche_emballage_"),
46
+ "by_plane": item.get("code_avion", False),
47
+ }
48
+
49
+
50
+ def _format_base_carbone_result(item: dict[str, Any]) -> dict[str, Any]:
51
+ """Formate un résultat Base Carbone en format unifié."""
52
+ # Construire le nom complet
53
+ name_parts = [item.get("Nom_base_français", "")]
54
+ if item.get("Nom_attribut_français"):
55
+ name_parts.append(item.get("Nom_attribut_français"))
56
+ if item.get("Nom_frontière_français"):
57
+ name_parts.append(f"({item.get('Nom_frontière_français')})")
58
+
59
+ return {
60
+ "source": "base_carbone",
61
+ "id": item.get("Identifiant_de_l'élément", ""),
62
+ "name": " - ".join(filter(None, name_parts)),
63
+ "name_en": item.get("Nom_base_anglais", ""),
64
+ "value": item.get("Total_poste_non_décomposé", 0),
65
+ "unit": item.get("Unité_français", ""),
66
+ "category": item.get("Code_de_la_catégorie", ""),
67
+ "uncertainty": item.get("Incertitude"),
68
+ "dqr": item.get("Qualité"),
69
+ "status": item.get("Statut_de_l'élément"),
70
+ "validity": item.get("Période_de_validité"),
71
+ "methodology": {
72
+ "source": item.get("Source"),
73
+ "program": item.get("Programme"),
74
+ "url": item.get("Url_du_programme"),
75
+ "contributor": item.get("Contributeur"),
76
+ "structure": item.get("Structure"),
77
+ "comment": item.get("Commentaire_français"),
78
+ "regulations": item.get("Réglementations"),
79
+ },
80
+ "decomposition_gas": {
81
+ "co2_fossil": item.get("CO2f"),
82
+ "co2_biogenic": item.get("CO2b"),
83
+ "ch4_fossil": item.get("CH4f"),
84
+ "ch4_biogenic": item.get("CH4b"),
85
+ "n2o": item.get("N2O"),
86
+ "other_ghg": item.get("Autres_GES"),
87
+ },
88
+ "quality_indicators": {
89
+ "M": item.get("Qualité_M"),
90
+ "TeR": item.get("Qualité_TeR"),
91
+ "C": item.get("Qualité_C"),
92
+ "TiR": item.get("Qualité_TiR"),
93
+ "GR": item.get("Qualité_GR"),
94
+ "P": item.get("Qualité_P"),
95
+ "transparency": item.get("Transparence"),
96
+ },
97
+ "location": item.get("Localisation_géographique"),
98
+ }
99
+
100
+
101
+ # ============================================================================
102
+ # OUTILS MCP
103
+ # ============================================================================
104
+
105
+ async def search_emission_factors(
106
+ query: str,
107
+ source: str = "all",
108
+ size: int = 10
109
+ ) -> str:
110
+ """Recherche unifiée de facteurs d'émission dans AGRIBALYSE et Base Carbone.
111
+
112
+ Permet de trouver des facteurs d'émission (FE) pour calculer l'impact carbone
113
+ de différentes activités : alimentation, transport, énergie, déchets, équipements.
114
+
115
+ Args:
116
+ query: Terme de recherche (ex: "boeuf", "camion diesel", "gaz naturel").
117
+ source: Source à interroger - "all" (les deux), "agribalyse" (alimentation),
118
+ ou "base_carbone" (transport, énergie, déchets, équipements).
119
+ size: Nombre de résultats par source (défaut: 10, max: 50).
120
+
121
+ Returns:
122
+ JSON avec les résultats unifiés des deux bases, incluant :
123
+ - id, name, value (kgCO2e), unit, category
124
+ - source d'origine (agribalyse/base_carbone)
125
+ - métadonnées de qualité (DQR, incertitude)
126
+
127
+ Example:
128
+ search_emission_factors("poulet", source="agribalyse")
129
+ search_emission_factors("camion", source="base_carbone")
130
+ search_emission_factors("électricité")
131
+ """
132
+ results = []
133
+ sources_count = {"agribalyse": 0, "base_carbone": 0}
134
+ size = min(size, 50)
135
+
136
+ try:
137
+ # Recherche AGRIBALYSE
138
+ if source in ["all", "agribalyse"]:
139
+ client = _get_agribalyse()
140
+ agri_data = await client.search(query, size=size)
141
+ for item in agri_data.get("results", []):
142
+ results.append(_format_agribalyse_result(item))
143
+ sources_count["agribalyse"] += 1
144
+
145
+ # Recherche Base Carbone
146
+ if source in ["all", "base_carbone"]:
147
+ client = _get_base_carbone()
148
+ bc_data = await client.search(query, size=size)
149
+ for item in bc_data.get("results", []):
150
+ if item.get("Type_Ligne") == "Elément":
151
+ results.append(_format_base_carbone_result(item))
152
+ sources_count["base_carbone"] += 1
153
+
154
+ return json.dumps({
155
+ "total": len(results),
156
+ "sources": sources_count,
157
+ "results": results
158
+ }, ensure_ascii=False, indent=2)
159
+
160
+ except Exception as e:
161
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
162
+
163
+
164
+ async def get_emission_factor_details(
165
+ identifier: str,
166
+ source: str = "base_carbone"
167
+ ) -> str:
168
+ """Obtient les détails méthodologiques complets d'un facteur d'émission.
169
+
170
+ Retourne toutes les informations disponibles sur un FE : valeur, incertitude,
171
+ source méthodologique, décomposition par gaz, indicateurs de qualité, etc.
172
+ Essentiel pour comprendre comment le FE a été calculé et sa fiabilité.
173
+
174
+ Args:
175
+ identifier: Identifiant du FE (Code_CIQUAL pour AGRIBALYSE,
176
+ Identifiant_de_l'élément pour Base Carbone).
177
+ source: "agribalyse" ou "base_carbone".
178
+
179
+ Returns:
180
+ JSON avec tous les détails méthodologiques :
181
+ - Valeur et unité
182
+ - Incertitude et indicateurs de qualité (DQR, M, TeR, C, TiR, GR, P)
183
+ - Source, programme, contributeur
184
+ - Décomposition par gaz (CO2f, CO2b, CH4, N2O)
185
+ - Commentaires méthodologiques
186
+ - Période de validité et statut
187
+
188
+ Example:
189
+ get_emission_factor_details("27002", source="base_carbone")
190
+ get_emission_factor_details("13050", source="agribalyse")
191
+ """
192
+ try:
193
+ if source == "agribalyse":
194
+ client = _get_agribalyse()
195
+ item = await client.get_by_code(int(identifier))
196
+ if item:
197
+ result = _format_agribalyse_result(item)
198
+ # Ajouter tous les impacts environnementaux
199
+ result["all_impacts"] = {
200
+ "climate_change": item.get("Changement_climatique"),
201
+ "acidification": item.get("Acidification_terrestre_et_eaux_douces"),
202
+ "eutrophication_marine": item.get("Eutrophisation_marine"),
203
+ "eutrophication_freshwater": item.get("Eutrophisation_eaux_douces"),
204
+ "eutrophication_terrestrial": item.get("Eutrophisation_terrestre"),
205
+ "land_use": item.get("Utilisation_du_sol"),
206
+ "water_depletion": item.get("Épuisement_des_ressources_eau"),
207
+ "energy_depletion": item.get("Épuisement_des_ressources_énergétiques"),
208
+ "mineral_depletion": item.get("Épuisement_des_ressources_minéraux"),
209
+ "particulate_matter": item.get("Particules_fines"),
210
+ "ozone_formation": item.get("Formation_photochimique_d'ozone"),
211
+ "ozone_depletion": item.get("Appauvrissement_de_la_couche_d'ozone"),
212
+ "ionizing_radiation": item.get("Rayonnements_ionisants"),
213
+ "ecotoxicity": item.get("Écotoxicité_pour_écosystèmes_aquatiques_d'eau_douce"),
214
+ "toxicity_cancer": item.get("Effets_toxicologiques_sur_la_santé_humaine___substances_cancérogènes"),
215
+ "toxicity_non_cancer": item.get("Effets_toxicologiques_sur_la_santé_humaine___substances_non-cancérogènes"),
216
+ }
217
+ return json.dumps(result, ensure_ascii=False, indent=2)
218
+ return json.dumps({"error": f"Aliment {identifier} non trouvé"})
219
+
220
+ else: # base_carbone
221
+ client = _get_base_carbone()
222
+ items = await client.get_by_id(identifier)
223
+ if items:
224
+ # Récupérer l'élément principal et ses postes
225
+ main_element = None
226
+ posts = []
227
+ for item in items:
228
+ if item.get("Type_Ligne") == "Elément":
229
+ main_element = _format_base_carbone_result(item)
230
+ elif item.get("Type_Ligne") == "Poste":
231
+ posts.append({
232
+ "post_name": item.get("Nom_poste_français"),
233
+ "post_type": item.get("Type_poste"),
234
+ "value": item.get("Total_poste_non_décomposé"),
235
+ })
236
+
237
+ if main_element:
238
+ main_element["decomposition_posts"] = posts
239
+ return json.dumps(main_element, ensure_ascii=False, indent=2)
240
+
241
+ return json.dumps({"error": f"FE {identifier} non trouvé"})
242
+
243
+ except Exception as e:
244
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
245
+
246
+
247
+ async def compare_emission_factors(
248
+ id1: str,
249
+ id2: str,
250
+ source1: str = "base_carbone",
251
+ source2: str = "base_carbone"
252
+ ) -> str:
253
+ """Compare deux facteurs d'émission avec analyse méthodologique.
254
+
255
+ Permet de comparer deux FE pour une analyse de sensibilité : écart de valeur,
256
+ comparaison des incertitudes, différences de périmètre méthodologique.
257
+ Utile pour choisir entre deux options (ex: diesel vs électrique).
258
+
259
+ Args:
260
+ id1: Identifiant du premier FE.
261
+ id2: Identifiant du second FE.
262
+ source1: Source du premier FE ("agribalyse" ou "base_carbone").
263
+ source2: Source du second FE ("agribalyse" ou "base_carbone").
264
+
265
+ Returns:
266
+ JSON avec la comparaison détaillée :
267
+ - Valeurs des deux FE
268
+ - Différence absolue et relative (%)
269
+ - Plage de différence compte tenu des incertitudes
270
+ - Comparaison des qualités de données
271
+ - Recommandation d'usage
272
+
273
+ Example:
274
+ compare_emission_factors("28022", "28024") # VUL diesel vs électrique
275
+ compare_emission_factors("13050", "13029", "agribalyse", "agribalyse") # Boeuf vs poulet
276
+ """
277
+ try:
278
+ # Récupérer les détails des deux FE
279
+ details1_json = await get_emission_factor_details(id1, source1)
280
+ details2_json = await get_emission_factor_details(id2, source2)
281
+
282
+ details1 = json.loads(details1_json)
283
+ details2 = json.loads(details2_json)
284
+
285
+ if "error" in details1:
286
+ return json.dumps({"error": f"FE1: {details1['error']}"})
287
+ if "error" in details2:
288
+ return json.dumps({"error": f"FE2: {details2['error']}"})
289
+
290
+ # Calculs de comparaison
291
+ val1 = details1.get("value", 0)
292
+ val2 = details2.get("value", 0)
293
+
294
+ abs_diff = val2 - val1
295
+ rel_diff = ((val2 - val1) / val1 * 100) if val1 != 0 else None
296
+
297
+ # Calcul avec incertitudes
298
+ unc1 = details1.get("uncertainty", 0) or 0
299
+ unc2 = details2.get("uncertainty", 0) or 0
300
+
301
+ lower_bound = None
302
+ upper_bound = None
303
+ if unc1 > 0 or unc2 > 0:
304
+ val1_min = val1 * (1 - unc1/100)
305
+ val1_max = val1 * (1 + unc1/100)
306
+ val2_min = val2 * (1 - unc2/100)
307
+ val2_max = val2 * (1 + unc2/100)
308
+
309
+ if val1 > 0:
310
+ lower_bound = (val2_min - val1_max) / val1 * 100
311
+ upper_bound = (val2_max - val1_min) / val1 * 100
312
+
313
+ # Recommandation
314
+ recommendation = None
315
+ if rel_diff is not None:
316
+ if abs(rel_diff) < 10:
317
+ recommendation = "Écart faible (<10%), les deux options sont équivalentes."
318
+ elif rel_diff < -20:
319
+ recommendation = f"Option 2 ({details2['name']}) significativement meilleure (-{abs(rel_diff):.0f}%)."
320
+ elif rel_diff > 20:
321
+ recommendation = f"Option 1 ({details1['name']}) significativement meilleure (+{rel_diff:.0f}%)."
322
+ else:
323
+ recommendation = "Écart modéré, considérer d'autres critères (coût, faisabilité)."
324
+
325
+ return json.dumps({
326
+ "factor1": {
327
+ "id": id1,
328
+ "source": source1,
329
+ "name": details1.get("name"),
330
+ "value": val1,
331
+ "unit": details1.get("unit"),
332
+ "uncertainty": unc1,
333
+ "dqr": details1.get("dqr"),
334
+ },
335
+ "factor2": {
336
+ "id": id2,
337
+ "source": source2,
338
+ "name": details2.get("name"),
339
+ "value": val2,
340
+ "unit": details2.get("unit"),
341
+ "uncertainty": unc2,
342
+ "dqr": details2.get("dqr"),
343
+ },
344
+ "comparison": {
345
+ "absolute_difference": round(abs_diff, 4),
346
+ "relative_difference_percent": round(rel_diff, 1) if rel_diff else None,
347
+ "range_with_uncertainty": {
348
+ "lower_bound_percent": round(lower_bound, 1) if lower_bound else None,
349
+ "upper_bound_percent": round(upper_bound, 1) if upper_bound else None,
350
+ },
351
+ "recommendation": recommendation,
352
+ }
353
+ }, ensure_ascii=False, indent=2)
354
+
355
+ except Exception as e:
356
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
357
+
358
+
359
+ async def search_food(
360
+ query: str,
361
+ group: str = "",
362
+ size: int = 10
363
+ ) -> str:
364
+ """Recherche d'aliments dans la base AGRIBALYSE.
365
+
366
+ AGRIBALYSE contient les impacts environnementaux de 2500+ produits alimentaires
367
+ français, calculés sur tout le cycle de vie (du champ à l'assiette).
368
+
369
+ Args:
370
+ query: Terme de recherche (ex: "steak", "yaourt", "pain").
371
+ group: Filtrer par groupe d'aliment (ex: "viandes", "produits laitiers").
372
+ Laisser vide pour chercher dans tous les groupes.
373
+ size: Nombre de résultats (défaut: 10, max: 50).
374
+
375
+ Returns:
376
+ JSON avec les aliments trouvés :
377
+ - Nom, code CIQUAL, groupe/sous-groupe
378
+ - Impact climatique (kgCO2e/kg)
379
+ - Décomposition (fossile, biogénique, changement d'affectation)
380
+ - Score environnemental global (EF Score)
381
+ - Qualité des données (DQR)
382
+
383
+ Example:
384
+ search_food("boeuf")
385
+ search_food("fromage", group="produits laitiers")
386
+ """
387
+ try:
388
+ client = _get_agribalyse()
389
+ data = await client.search(query, size=min(size, 50))
390
+
391
+ results = []
392
+ for item in data.get("results", []):
393
+ # Filtrer par groupe si spécifié
394
+ if group and group.lower() not in item.get("Groupe_d'aliment", "").lower():
395
+ continue
396
+ results.append(_format_agribalyse_result(item))
397
+
398
+ return json.dumps({
399
+ "total": len(results),
400
+ "results": results
401
+ }, ensure_ascii=False, indent=2)
402
+
403
+ except Exception as e:
404
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
405
+
406
+
407
+ async def get_transport_factors(
408
+ vehicle_type: str = "",
409
+ fuel_type: str = "",
410
+ size: int = 20
411
+ ) -> str:
412
+ """Recherche de facteurs d'émission pour le transport.
413
+
414
+ Permet de trouver les FE de différents modes de transport : routier (voiture,
415
+ VUL, poids lourd), ferroviaire, maritime, aérien. Inclut différentes motorisations
416
+ (diesel, essence, électrique, hybride, GNV).
417
+
418
+ Args:
419
+ vehicle_type: Type de véhicule (ex: "voiture", "VUL", "poids lourd", "train").
420
+ fuel_type: Type de carburant (ex: "diesel", "essence", "électrique", "hybride").
421
+ size: Nombre de résultats (défaut: 20).
422
+
423
+ Returns:
424
+ JSON avec les FE transport :
425
+ - Nom du véhicule et motorisation
426
+ - Valeur (kgCO2e/km ou kgCO2e/t.km)
427
+ - Incertitude
428
+ - Source méthodologique
429
+
430
+ Example:
431
+ get_transport_factors("VUL", "diesel")
432
+ get_transport_factors("poids lourd")
433
+ get_transport_factors(fuel_type="électrique")
434
+ """
435
+ try:
436
+ client = _get_base_carbone()
437
+ data = await client.search_transport(vehicle_type or None, fuel_type or None, size)
438
+
439
+ results = []
440
+ for item in data.get("results", []):
441
+ if item.get("Type_Ligne") == "Elément":
442
+ results.append(_format_base_carbone_result(item))
443
+
444
+ return json.dumps({
445
+ "total": len(results),
446
+ "results": results
447
+ }, ensure_ascii=False, indent=2)
448
+
449
+ except Exception as e:
450
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
451
+
452
+
453
+ async def get_energy_factors(
454
+ energy_type: str = "",
455
+ size: int = 20
456
+ ) -> str:
457
+ """Recherche de facteurs d'émission pour l'énergie.
458
+
459
+ Permet de trouver les FE des différentes sources d'énergie : gaz naturel,
460
+ électricité, fioul, charbon, bois, etc. Utile pour calculer l'impact
461
+ du chauffage, de la cuisson, ou de la consommation électrique.
462
+
463
+ Args:
464
+ energy_type: Type d'énergie (ex: "gaz naturel", "électricité", "fioul").
465
+ size: Nombre de résultats (défaut: 20).
466
+
467
+ Returns:
468
+ JSON avec les FE énergie :
469
+ - Type d'énergie
470
+ - Valeur (kgCO2e/kWh ou kgCO2e/kg)
471
+ - Décomposition par gaz (CO2, CH4, N2O)
472
+ - Incertitude
473
+
474
+ Example:
475
+ get_energy_factors("gaz naturel")
476
+ get_energy_factors("électricité")
477
+ """
478
+ try:
479
+ client = _get_base_carbone()
480
+ data = await client.search_energy(energy_type or None, size)
481
+
482
+ results = []
483
+ for item in data.get("results", []):
484
+ if item.get("Type_Ligne") == "Elément":
485
+ results.append(_format_base_carbone_result(item))
486
+
487
+ return json.dumps({
488
+ "total": len(results),
489
+ "results": results
490
+ }, ensure_ascii=False, indent=2)
491
+
492
+ except Exception as e:
493
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
494
+
495
+
496
+ async def get_waste_factors(
497
+ waste_type: str = "",
498
+ treatment: str = "",
499
+ size: int = 20
500
+ ) -> str:
501
+ """Recherche de facteurs d'émission pour les déchets.
502
+
503
+ Permet de trouver les FE des différents types de déchets selon leur
504
+ traitement : compostage, incinération, stockage (décharge), méthanisation.
505
+
506
+ Args:
507
+ waste_type: Type de déchet (ex: "alimentaire", "plastique", "papier").
508
+ treatment: Mode de traitement (ex: "compost", "incinération", "stockage").
509
+ size: Nombre de résultats (défaut: 20).
510
+
511
+ Returns:
512
+ JSON avec les FE déchets :
513
+ - Type de déchet et traitement
514
+ - Valeur (kgCO2e/tonne)
515
+ - Décomposition par gaz
516
+ - Incertitude
517
+
518
+ Example:
519
+ get_waste_factors("alimentaire", "compost")
520
+ get_waste_factors("plastique", "incinération")
521
+ """
522
+ try:
523
+ client = _get_base_carbone()
524
+ data = await client.search_waste(waste_type or None, treatment or None, size)
525
+
526
+ results = []
527
+ for item in data.get("results", []):
528
+ if item.get("Type_Ligne") == "Elément":
529
+ results.append(_format_base_carbone_result(item))
530
+
531
+ return json.dumps({
532
+ "total": len(results),
533
+ "results": results
534
+ }, ensure_ascii=False, indent=2)
535
+
536
+ except Exception as e:
537
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
538
+
539
+
540
+ async def get_equipment_factors(
541
+ equipment_type: str = "",
542
+ size: int = 20
543
+ ) -> str:
544
+ """Recherche de facteurs d'émission pour les équipements.
545
+
546
+ Permet de trouver les FE des équipements informatiques et électroménagers :
547
+ ordinateurs, téléphones, télévisions, réfrigérateurs, etc.
548
+ Inclut la décomposition par poste (matières premières, fabrication, transport).
549
+
550
+ Args:
551
+ equipment_type: Type d'équipement (ex: "ordinateur", "téléphone", "télévision").
552
+ size: Nombre de résultats (défaut: 20).
553
+
554
+ Returns:
555
+ JSON avec les FE équipements :
556
+ - Type d'équipement
557
+ - Valeur (kgCO2e/unité)
558
+ - Décomposition par poste (si disponible)
559
+ - Incertitude
560
+
561
+ Example:
562
+ get_equipment_factors("ordinateur portable")
563
+ get_equipment_factors("smartphone")
564
+ """
565
+ try:
566
+ client = _get_base_carbone()
567
+ data = await client.search_equipment(equipment_type or None, size)
568
+
569
+ results = []
570
+ for item in data.get("results", []):
571
+ if item.get("Type_Ligne") == "Elément":
572
+ results.append(_format_base_carbone_result(item))
573
+
574
+ return json.dumps({
575
+ "total": len(results),
576
+ "results": results
577
+ }, ensure_ascii=False, indent=2)
578
+
579
+ except Exception as e:
580
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
581
+
582
+
583
+ async def get_categories(
584
+ source: str = "all"
585
+ ) -> str:
586
+ """Liste les catégories disponibles dans les bases de données.
587
+
588
+ Permet de découvrir les catégories de facteurs d'émission disponibles
589
+ pour orienter les recherches.
590
+
591
+ Args:
592
+ source: "agribalyse" (groupes d'aliments), "base_carbone" (catégories FE),
593
+ ou "all" (les deux).
594
+
595
+ Returns:
596
+ JSON avec les catégories par source.
597
+
598
+ Example:
599
+ get_categories("agribalyse")
600
+ get_categories("base_carbone")
601
+ """
602
+ try:
603
+ result = {}
604
+
605
+ if source in ["all", "agribalyse"]:
606
+ client = _get_agribalyse()
607
+ categories = await client.get_categories()
608
+ result["agribalyse"] = {
609
+ "description": "Groupes d'aliments AGRIBALYSE",
610
+ "categories": categories
611
+ }
612
+
613
+ if source in ["all", "base_carbone"]:
614
+ client = _get_base_carbone()
615
+ categories = await client.get_categories()
616
+ # Extraire les catégories racines
617
+ root_categories = set()
618
+ for cat in categories:
619
+ root = cat["category"].split(" > ")[0]
620
+ root_categories.add(root)
621
+
622
+ result["base_carbone"] = {
623
+ "description": "Catégories principales Base Carbone",
624
+ "categories": sorted(list(root_categories))
625
+ }
626
+
627
+ return json.dumps(result, ensure_ascii=False, indent=2)
628
+
629
+ except Exception as e:
630
+ return json.dumps({"error": str(e)}, ensure_ascii=False)
631
+
632
+
633
+ # Liste des outils pour export
634
+ ALL_TOOLS = [
635
+ search_emission_factors,
636
+ get_emission_factor_details,
637
+ compare_emission_factors,
638
+ search_food,
639
+ get_transport_factors,
640
+ get_energy_factors,
641
+ get_waste_factors,
642
+ get_equipment_factors,
643
+ get_categories,
644
+ ]